Utility_Apps/Building_Code_Assistant/Building_Code_Assistant.html

5228 lines
No EOL
278 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en" data-theme="light"> <!-- Added data-theme for night mode -->
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Building Code Compliance Assistant</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/all.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js" integrity="sha512-BNaRQnYJYiPSqHHDb58B0yaPfCu+Wgds8Gp/gU33kqBtgNS4tSPHuGibyoeqMV/TJlSKda6FXzoEyYGjTe+vXA==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js" integrity="sha512-qZvrmS2ekKPF2mSznTQsxqPgnpkI4FSoArFZcUydJxkhXtZNXTFGhvBPqEzGqxHRLgSz1zwNUyiURKlHOf7r0g==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
<script>
window.jspdf = window.jspdf || window.jsPDF || {};
if (!window.jspdf.jsPDF && window.jsPDF) {
window.jspdf.jsPDF = window.jsPDF;
}
</script>
<style>
:root {
--primary-color: #2c3e50;
--secondary-color: #3498db;
--accent-color: #e74c3c;
--light-color: #ecf0f1;
--dark-color: #2c3e50; /* Base dark color, may not be used if overridden by dark theme */
--success-color: #27ae60;
--warning-color: #f39c12;
--danger-color: #e74c3c;
--border-radius: 4px;
--box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
/* Light Theme Defaults */
--bg-color: #f5f7fa;
--text-color: #333;
--header-bg: var(--primary-color);
--header-text: white;
--nav-bg: var(--light-color);
--nav-text: #333;
--nav-border: #ddd;
--nav-active-bg: var(--secondary-color);
--nav-active-text: white;
--module-bg: white;
--form-section-bg: #f8f9fa;
--input-bg: white;
--input-border: #ddd;
--output-panel-bg: #f9f9f9;
--table-header-bg: var(--light-color);
--table-row-even-bg: #f9f9f9;
--footer-bg: var(--primary-color);
--footer-text: white;
}
html[data-theme="dark"] {
--primary-color: #3498db; /* Light blue for dark mode primary */
--secondary-color: #2980b9; /* Darker blue */
--accent-color: #e74c3c;
--light-color: #3a3a3a; /* Darker light color */
--dark-color: #ecf0f1; /* Light text on dark bg */
--bg-color: #1a1a1a;
--text-color: #f0f0f0;
--header-bg: #232323;
--header-text: #f0f0f0;
--nav-bg: #282828;
--nav-text: #e0e0e0;
--nav-border: #444;
--nav-active-bg: var(--primary-color);
--nav-active-text: #1a1a1a;
--module-bg: #2c2c2c;
--form-section-bg: #232323;
--input-bg: #3a3a3a;
--input-border: #555;
--output-panel-bg: #232323;
--table-header-bg: #3a3a3a;
--table-row-even-bg: #282828;
--footer-bg: #232323;
--footer-text: #f0f0f0;
--box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
}
body {
background-color: var(--bg-color);
color: var(--text-color);
line-height: 1.6;
}
.app-container {
display: grid;
grid-template-rows: auto 1fr auto;
min-height: 100vh;
max-width: 1400px;
margin: 0 auto;
background-color: var(--module-bg);
box-shadow: var(--box-shadow);
}
.app-header {
background-color: var(--header-bg);
color: var(--header-text);
padding: 0.5rem 1.5rem; /* Reduced thickness */
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.app-header h1 {
font-size: 1.3rem; /* Reduced to fit */
margin: 0;
white-space: nowrap;
}
.project-info-container { /* New wrapper for all controls on the right */
display: flex;
flex-wrap: wrap;
gap: 1rem;
align-items: center;
}
.project-controls {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.project-meta {
display: flex;
gap: 0.5rem;
align-items: center;
flex-shrink: 0;
}
.project-status {
display: flex;
gap: 0.5rem; /* Reduced gap */
align-items: center;
font-size: 0.8em; /* Smaller font */
color: var(--header-text);
opacity: 0.8;
}
.status-dot {
width: 8px; /* Smaller dot */
height: 8px;
border-radius: 50%;
background: #7f8c8d;
}
.status-dot.active {
background: var(--success-color);
}
.project-controls button,
.quick-actions button {
background-color: var(--secondary-color);
color: var(--header-text); /* Ensuring contrast with button bg */
border: none;
padding: 0.5rem 1rem; /* Adjusted padding */
font-size: 0.85em;
gap: 0.4rem;
border-radius: 6px;
cursor: pointer;
transition: background-color 0.3s;
display: inline-flex;
align-items: center;
}
html[data-theme="dark"] .project-controls button,
html[data-theme="dark"] .quick-actions button {
color: var(--bg-color); /* Text color for dark theme buttons */
}
.project-controls button:hover,
.quick-actions button:hover {
background-color: #2980b9;
}
.project-controls button.secondary,
.quick-actions button.secondary {
background-color: #7f8c8d;
}
.project-controls button.secondary:hover,
.quick-actions button.secondary:hover {
background-color: #6c7a7b;
}
.project-meta input {
padding: 0.4rem 0.6rem; /* Adjusted padding */
border: 1px solid var(--input-border);
background-color: var(--input-bg);
color: var(--text-color);
border-radius: var(--border-radius);
min-width: 150px; /* Adjusted min-width */
font-size: 0.85em;
transition: border-color 0.2s;
}
.main-content {
display: flex;
flex: 1;
overflow: hidden;
}
.chapter-nav {
width: 250px;
background-color: var(--nav-bg);
color: var(--nav-text);
border-right: 1px solid var(--nav-border);
overflow-y: auto;
flex-shrink: 0;
padding: 1rem 0;
}
.chapter-nav ul {
list-style-type: none;
}
.chapter-nav li {
padding: 0.8rem 1.2rem;
position: relative;
display: flex;
align-items: center;
gap: 0.8rem;
border-bottom: 1px solid var(--nav-border);
cursor: pointer;
transition: background-color 0.3s;
color: var(--nav-text);
}
.chapter-nav li span {
flex-grow: 1;
}
.chapter-nav li:hover {
background-color: var(--secondary-color);
color: var(--nav-active-text);
}
.chapter-nav li.active {
background-color: var(--nav-active-bg);
color: var(--nav-active-text);
}
.chapter-nav li.active:before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--primary-color); /* Use primary color from theme */
}
.chapter-nav li i.chapter-status-dot {
font-size: 0.7em;
width: 10px;
height: 10px;
border-radius: 50%;
background-color: #78909c; /* Neutral/grey default */
flex-shrink: 0;
margin-right: 5px; /* Spacing from text */
}
.chapter-nav li i.chapter-status-dot.completed {
background-color: var(--success-color);
}
.chapter-nav li i.chapter-status-dot.in-progress {
background-color: var(--warning-color);
}
.chapter-nav li.disabled {
color: #aaa;
cursor: not-allowed;
}
html[data-theme="dark"] .chapter-nav li.disabled {
color: #666;
}
.chapter-nav li.disabled:hover {
background-color: var(--nav-bg);
}
.content-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.module-container {
flex: 1;
padding: 1.5rem;
overflow-y: auto;
background-color: var(--module-bg);
}
.module-container h2 {
border-bottom: 2px solid var(--primary-color);
padding-bottom: 0.5rem;
margin-bottom: 1.5rem;
color: var(--text-color);
}
.form-section {
background: var(--form-section-bg);
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.form-section h3 {
font-size: 1.1em;
color: var(--primary-color);
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
position: sticky;
top: 0;
background: var(--form-section-bg);
z-index: 2;
padding-top: 0.5rem;
padding-bottom: 0.5rem;
margin-left: -1.5rem;
margin-right: -1.5rem;
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.form-section h3 i.fa-info-circle {
font-size: 0.9em;
color: var(--secondary-color);
cursor: help;
}
.welcome-screen {
text-align: center;
padding: 2rem;
}
.welcome-screen h2 {
margin-bottom: 1rem;
color: var(--primary-color);
}
.quick-actions {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 2rem;
flex-wrap: wrap;
}
.output-panel {
border-top: 2px solid var(--primary-color);
max-height: 300px;
overflow-y: auto;
background-color: var(--output-panel-bg);
position: relative;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(120, 120, 120, 0.7); /* Darker for dark mode */
display: flex;
justify-content: center;
align-items: center;
font-size: 1.2em;
color: var(--text-color);
z-index: 10;
visibility: hidden;
opacity: 0;
transition: opacity 0.3s, visibility 0s linear 0.3s;
}
html[data-theme="light"] .loading-overlay {
background-color: rgba(255, 255, 255, 0.8);
color: var(--primary-color); /* primary color from light theme */
}
.loading-overlay.visible {
visibility: visible;
opacity: 1;
transition: opacity 0.3s;
}
.output-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.8rem 1.5rem;
background-color: var(--header-bg); /* Use themed header bg */
color: var(--header-text); /* Use themed header text */
position: sticky;
top: 0;
z-index: 5;
}
.output-controls {
display: flex;
gap: 0.5rem;
}
.output-controls button {
background-color: transparent;
color: var(--header-text);
border: 1px solid var(--header-text);
padding: 0.4rem 0.8rem;
gap: 0.4rem;
border-radius: var(--border-radius);
cursor: pointer;
transition: all 0.3s;
display: inline-flex;
align-items: center;
}
.output-controls button:hover {
background-color: var(--header-text);
color: var(--header-bg);
}
.output-content {
padding: 1.5rem;
}
.compliance-message {
padding: 1rem;
margin: 1rem 0;
display: flex;
gap: 0.8rem;
align-items: flex-start;
border-left-width: 4px;
border-left-style: solid;
background-color: var(--form-section-bg); /* Use themed section bg */
color: var(--text-color);
border-radius: 0 var(--border-radius) var(--border-radius) 0;
font-size: 0.9em;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
html[data-theme="dark"] .compliance-message {
box-shadow: 0 1px 3px rgba(255,255,255,0.05);
}
.compliance-message p { margin-bottom: 0.3rem; }
.compliance-message small { color: #888; }
html[data-theme="dark"] .compliance-message small { color: #aaa; }
.compliance-message i.fas {
font-size: 1.2em;
margin-top: 2px;
}
.compliance-message.success { border-left-color: var(--success-color); }
.compliance-message.warning { border-left-color: var(--warning-color); }
.compliance-message.error { border-left-color: var(--danger-color); }
.success-color-text { color: var(--success-color); }
.danger-color-text { color: var(--danger-color); }
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
color: var(--text-color);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--input-border);
background-color: var(--input-bg);
color: var(--text-color);
border-radius: var(--border-radius);
}
.form-group input.form-control-plaintext {
background-color: var(--form-section-bg); /* Themed */
border: 1px solid var(--input-border); /* Themed */
opacity: 0.8;
cursor: default;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus {
outline: none;
border-color: var(--secondary-color);
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
}
.form-row {
display: flex;
gap: 1rem;
}
.form-row .form-group {
flex: 1;
}
.module-container button {
background-color: var(--secondary-color);
color: var(--bg-color); /* To contrast with secondary color */
border: none;
padding: 0.75rem 1.5rem;
border-radius: var(--border-radius);
cursor: pointer;
transition: background-color 0.3s;
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
html[data-theme="dark"] .module-container button {
color: var(--bg-color); /* Ensure high contrast on dark theme buttons */
}
.module-container button:hover {
background-color: #2980b9;
}
.module-container button:disabled {
background-color: #bdc3c7;
color: #777;
cursor: not-allowed;
}
html[data-theme="dark"] .module-container button:disabled {
background-color: #555;
color: #999;
}
.module-container button.secondary { background-color: #95a5a6; }
.module-container button.secondary:hover { background-color: #7f8c8d; }
.module-container button.danger { background-color: var(--danger-color); }
.module-container button.danger:hover { background-color: #c0392b; }
.table-container {
overflow-x: auto;
margin-bottom: 1.5rem;
border: 1px solid var(--nav-border);
border-radius: var(--border-radius);
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.9em;
}
th, td {
padding: 0.75rem;
text-align: left;
border-bottom: 1px solid var(--nav-border);
border-right: 1px solid var(--nav-border); /* Darker vertical lines */
}
td:last-child, th:last-child {
border-right: none;
}
th {
background-color: var(--table-header-bg);
color: var(--primary-color);
font-weight: 600;
position: sticky;
top: 0;
z-index: 1;
}
html[data-theme="dark"] th {
color: var(--text-color);
}
tr:nth-child(even) {
background-color: var(--table-row-even-bg);
}
tr:hover {
background-color: var(--secondary-color);
color: var(--bg-color); /* Ensure text is visible on hover */
}
html[data-theme="dark"] tr:hover {
color: var(--bg-color);
}
.app-footer {
background-color: var(--footer-bg);
color: var(--footer-text);
padding: 0.5rem 1rem;
text-align: right;
font-size: 0.85em;
}
.status-bar {
display: flex;
justify-content: space-between;
align-items: center; /* Align night mode toggle */
}
#night-mode-toggle {
background: none;
border: none;
color: var(--footer-text);
font-size: 1.2em;
cursor: pointer;
padding: 0 0.5rem;
}
/* Responsive adjustments */
@media (max-width: 992px) {
.app-header {
flex-direction: column;
align-items: stretch; /* Make items full width when stacked */
}
.app-header h1 {
text-align: center; /* Center title when stacked */
margin-bottom: 0.5rem;
}
.project-info-container {
width: 100%;
justify-content: center;
gap: 0.5rem; /* Reduce gap for smaller screens */
}
.project-controls, .project-meta, .project-status {
justify-content: center;
width: auto; /* Allow them to size based on content */
}
}
@media (max-width: 768px) {
.app-header {
padding: 0.5rem; /* Further reduce padding on mobile */
}
.app-header h1 {
font-size: 1.1rem; /* Further reduce title */
}
.project-info-container {
flex-direction: column; /* Stack controls, meta, status */
align-items: stretch;
}
.project-controls, .project-meta, .project-status {
flex-direction: column;
align-items: stretch;
width: 100%;
gap: 0.3rem;
}
.project-meta input, .project-controls button {
width: 100%;
text-align: center;
}
.project-status { text-align: center; }
.main-content { flex-direction: column; }
.chapter-nav { width: 100%; border-right: none; border-bottom: 1px solid var(--nav-border); max-height: 200px; }
.form-row {
flex-direction: column;
gap: 0.5rem;
}
.output-panel {
max-height: 35vh; /* Slightly less for very small screens */
}
.compliance-message {
flex-direction: column;
align-items: start;
}
.compliance-message i.fas {
margin-bottom: 0.5rem;
}
.form-section h3 {
font-size: 1em;
margin-left: -1rem;
margin-right: -1rem;
padding-left: 1rem;
padding-right: 1rem;
}
}
</style>
</head>
<body>
<div class="app-container">
<header class="app-header">
<h1><i class="fas fa-building"></i> Building Code Compliance Assistant</h1>
<div class="project-info-container"> <!-- New wrapper -->
<div class="project-controls">
<button id="new-project"><i class="fas fa-file"></i> New</button>
<button id="load-project"><i class="fas fa-folder-open"></i> Load</button>
<button id="save-project"><i class="fas fa-save"></i> Save</button>
<button id="load-ibc-data-manual-header" class="secondary" title="Manually load the IBC JSON data file"><i class="fas fa-database"></i> Code Data</button>
</div>
<div class="project-meta">
<input type="text" id="project-name" placeholder="Project Name">
<input type="text" id="project-location" placeholder="Location">
</div>
<div class="project-status">
<div class="status-dot" id="project-status-dot"></div>
<span id="autosave-status">Autosave: Off</span>
</div>
</div>
</header>
<div class="main-content">
<nav class="chapter-nav">
<ul id="chapter-tabs">
<!-- Chapters will be populated by JavaScript -->
</ul>
</nav>
<div class="content-area">
<div class="module-container" id="module-container">
<div class="welcome-screen">
<h2>Welcome to the Building Code Compliance Assistant</h2>
<p>
To begin, please load the IBC code data (e.g., `Building_Code_Data_IBC_2018.json, or Building_Code_Data_IBC_2021.json, or Building_Code_Data_IBC_2024.json`) using the button below or the "Load Code Data" button in the header.
Then, select a chapter from the navigation menu to analyze your project.
</p>
<div class="quick-actions">
<button id="load-ibc-data-manual-welcome"><i class="fas fa-database"></i> Load IBC Code Data File</button>
<button id="quick-occupancy" disabled><i class="fas fa-users"></i> Occupancy</button>
<button id="quick-heights-areas" disabled><i class="fas fa-ruler-combined"></i> Heights & Areas</button>
<button id="quick-egress" disabled><i class="fas fa-door-open"></i> Egress Analysis</button>
<button id="quick-fire" disabled><i class="fas fa-fire-extinguisher"></i> Fire Protection</button>
</div>
</div>
</div>
<div class="output-panel" id="output-panel">
<div class="loading-overlay" id="pdf-loading-overlay">
<i class="fas fa-spinner fa-spin"></i>   Generating PDF...
</div>
<div class="output-header">
<h3>Code Compliance Report</h3>
<div class="output-controls">
<button id="copy-report"><i class="fas fa-copy"></i> Copy</button>
<button id="export-pdf"><i class="fas fa-file-pdf"></i> PDF</button>
<button id="clear-report"><i class="fas fa-trash"></i> Clear</button>
</div>
</div>
<div class="output-content" id="output-content">
<!-- Compliance messages will appear here -->
</div>
</div>
</div>
</div>
<footer class="app-footer">
<div class="status-bar">
<span id="status-message">Ready</span>
<div> <!-- Wrapper for night mode and code reference -->
<button id="night-mode-toggle" title="Toggle Night Mode"><i class="fas fa-moon"></i></button>
<span id="code-reference">IBC (No Data Loaded)</span>
</div>
</div>
</footer>
</div>
<input type="file" id="file-input" style="display: none;" accept=".json">
<input type="file" id="manual-json-data-input" style="display: none;" accept=".json">
<script>
// GLOBAL CODE DATA PLACEHOLDER
let G_CODE_DATA = null;
// --- Utility Function for Occupancy Dropdowns ---
function getOccupancyOptionsHtml(occupancyDataSource, selectedValue = '', includeGeneric = false) {
let optionsHtml = '<option value="">-- Select --</option>';
const occupancyClassData = G_CODE_DATA?.tables?.occupancyClassifications?.data || {};
const availableKeys = Object.keys(occupancyDataSource || {}).sort();
availableKeys.forEach(key => {
if (!includeGeneric && occupancyClassData[key]?.subgroups && Object.keys(occupancyClassData[key].subgroups).length > 0) {
if (!occupancyDataSource[key]) return;
}
const desc = occupancyClassData[key]?.description || key;
let subGroupText = '';
if (occupancyClassData[key]?.subgroups) {
const subKeys = Object.keys(occupancyClassData[key].subgroups);
if (subKeys.length > 0 && !(subKeys.length === 1 && subKeys[0] === key && Object.values(occupancyClassData[key].subgroups)[0].toLowerCase().includes('general'))) {
subGroupText = ` (${subKeys.join('/')})`;
}
}
if(occupancyDataSource[key]) {
optionsHtml += `<option value="${key}" ${selectedValue === key ? 'selected' : ''}>${key} - ${desc}${subGroupText}</option>`;
}
});
return optionsHtml;
}
// --- MODULE DEFINITIONS ---
class ScopeAdministrationModule {
constructor(app) {
this.app = app;
this.chapterId = 'scope-administration';
this.state = {
permitRequired: true,
codeEditionInUse: "Not Specified",
jurisdiction: "Generic",
notes: ""
};
}
render() {
const projectData = this.app.getProjectData();
this.state.codeEditionInUse = G_CODE_DATA?.codeEdition || "Not Specified";
this.state.jurisdiction = G_CODE_DATA?.jurisdiction || "Generic";
return `
<div class="scope-administration-module">
<h2>Chapter 1: Scope & Administration</h2>
<div class="form-section">
<h3>Project Identification & Code Context <i class="fas fa-info-circle" title="General project identifiers and the applicable code edition."></i></h3>
<p>This chapter outlines the administrative provisions of the code, including permits, inspections, and enforcement. Below are some general project-related administrative details.</p>
<div class="form-group">
<label>Project Name:</label>
<input type="text" value="${projectData.name || 'N/A'}" readonly class="form-control-plaintext">
</div>
<div class="form-group">
<label>Project Location:</label>
<input type="text" value="${projectData.location || 'N/A'}" readonly class="form-control-plaintext">
</div>
<div class="form-group">
<label>Code Edition (from loaded data):</label>
<input type="text" id="code-edition-display" value="${this.state.codeEditionInUse}" readonly class="form-control-plaintext">
</div>
<div class="form-group">
<label>Jurisdiction (from loaded data):</label>
<input type="text" id="jurisdiction-display" value="${this.state.jurisdiction}" readonly class="form-control-plaintext">
</div>
</div>
<div class="form-section">
<h3>Administrative Details <i class="fas fa-info-circle" title="Permit requirements and project-specific administrative notes."></i></h3>
<div class="form-group">
<label for="permit-required">Is a permit generally required for this type of work?</label>
<select id="permit-required">
<option value="true" ${this.state.permitRequired ? 'selected' : ''}>Yes</option>
<option value="false" ${!this.state.permitRequired ? 'selected' : ''}>No (e.g., minor repairs)</option>
</select>
<small>Refer to IBC Section 105 for specific permit requirements and exemptions.</small>
</div>
<div class="form-group">
<label for="admin-notes">Administrative Notes for Project:</label>
<textarea id="admin-notes" rows="4" placeholder="Local amendments, special inspections...">${this.state.notes}</textarea>
</div>
<button id="save-admin-data"><i class="fas fa-save"></i> Save Administrative Data</button>
</div>
<div class="form-section">
<h3>Key Administrative Sections (Illustrative - IBC 2021) <i class="fas fa-info-circle" title="Examples of relevant IBC sections for administration."></i></h3>
<ul>
<li><strong>Section 101: General</strong> - Scope, intent, applicability.</li>
<li><strong>Section 105: Permits</strong> - When permits are required, work exempt from permit.</li>
<li><strong>Section 107: Submittal Documents</strong> - Construction documents.</li>
<li><strong>Section 110: Inspections</strong> - Required inspections.</li>
<li><strong>Section 111: Certificate of Occupancy</strong> - Use and occupancy.</li>
</ul>
</div>
${!G_CODE_DATA ? '<p class="compliance-message error"><i class="fas fa-times-circle"></i> Code Data not loaded. Some information may be missing.</p>' : ''}
</div>
`;
}
initEvents() {
const permitRequiredEl = document.getElementById('permit-required');
const adminNotesEl = document.getElementById('admin-notes');
const codeEditionDisplayEl = document.getElementById('code-edition-display');
const jurisdictionDisplayEl = document.getElementById('jurisdiction-display');
const saveAdminDataBtn = document.getElementById('save-admin-data');
if (permitRequiredEl) permitRequiredEl.value = this.state.permitRequired.toString();
if (adminNotesEl) adminNotesEl.value = this.state.notes;
if (codeEditionDisplayEl && G_CODE_DATA?.codeEdition) codeEditionDisplayEl.value = G_CODE_DATA.codeEdition;
if (jurisdictionDisplayEl && G_CODE_DATA?.jurisdiction) jurisdictionDisplayEl.value = G_CODE_DATA.jurisdiction;
permitRequiredEl?.addEventListener('change', (e) => {
this.state.permitRequired = e.target.value === 'true';
});
adminNotesEl?.addEventListener('input', (e) => {
this.state.notes = e.target.value;
});
saveAdminDataBtn?.addEventListener('click', () => {
this.saveData();
this.app.addOutputMessage('Ch 1: Administrative data saved.', 'success');
});
}
saveData() {
const data = { ...this.state, codeEditionInUse: G_CODE_DATA?.codeEdition, jurisdiction: G_CODE_DATA?.jurisdiction };
this.app.updateProjectData(this.chapterId, data);
return data;
}
loadData(data) {
this.state = { ...this.state, ...data };
const permitRequiredEl = document.getElementById('permit-required');
if (permitRequiredEl) {
permitRequiredEl.value = this.state.permitRequired.toString();
const adminNotesEl = document.getElementById('admin-notes');
if (adminNotesEl) adminNotesEl.value = this.state.notes;
const codeEditionDisplayEl = document.getElementById('code-edition-display');
if (codeEditionDisplayEl) codeEditionDisplayEl.value = G_CODE_DATA?.codeEdition || this.state.codeEditionInUse;
const jurisdictionDisplayEl = document.getElementById('jurisdiction-display');
if (jurisdictionDisplayEl) jurisdictionDisplayEl.value = G_CODE_DATA?.jurisdiction || this.state.jurisdiction;
}
}
}
class DefinitionsModule {
constructor(app) {
this.app = app;
this.chapterId = 'definitions';
this.state = {
searchTerm: "",
filteredDefinitions: [],
selectedDefinitionKey: ""
};
this.allDefinitions = {};
}
_extractDefinitions() {
this.allDefinitions = {};
if (!G_CODE_DATA || !G_CODE_DATA.tables) return;
if (G_CODE_DATA.tables.constructionTypes?.data) {
for (const key in G_CODE_DATA.tables.constructionTypes.data) {
const item = G_CODE_DATA.tables.constructionTypes.data[key];
this.allDefinitions[`Construction Type ${key}`] = `${item.description}. Combustible: ${item.combustible}. Fire Resistance: ${item.fireResistanceRating}. Typical Materials: ${item.typicalMaterials}. (From Construction Types Table)`;
}
}
if (G_CODE_DATA.tables.occupancyClassifications?.data) {
for (const key in G_CODE_DATA.tables.occupancyClassifications.data) {
const item = G_CODE_DATA.tables.occupancyClassifications.data[key];
this.allDefinitions[`Occupancy Group ${key}`] = `${item.description}. (From Occupancy Classifications Table)`;
if (item.subgroups) {
Object.entries(item.subgroups).forEach(([sk, sv]) => {
this.allDefinitions[`Occupancy Subgroup ${sk}`] = `${sv}. (Belongs to Group ${key})`;
});
}
}
}
if (G_CODE_DATA.tables.interiorFinishRequirements?.data) {
this.allDefinitions["Flame Spread Index"] = "A comparative measure, expressed as a dimensionless number, derived from visual measurements of the spread of flame versus time for a material tested in accordance with ASTM E84 or UL 723. (General Definition)";
this.allDefinitions["Smoke Developed Index"] = "A comparative measure, expressed as a dimensionless number, derived from measurements of smoke density versus time for a material tested in accordance with ASTM E84 or UL 723. (General Definition)";
}
if (G_CODE_DATA.tables.occupantLoadFactors?.data) {
for (const key in G_CODE_DATA.tables.occupantLoadFactors.data) {
const item = G_CODE_DATA.tables.occupantLoadFactors.data[key];
const displayName = key.replace(/_/g, ' ').replace(/(?:^|\s)\S/g, a => a.toUpperCase());
this.allDefinitions[`${displayName} (Occupant Load Factor)`] = `Factor: ${item.factor} ${item.unit}. ${item.notes || ''} ${item.exceptions ? 'Exceptions: ' + item.exceptions : ''}`;
}
}
this.allDefinitions["Fire Separation Distance (FSD)"] = "The distance measured from the building face to one of the following: 1. The closest interior lot line. 2. The centerline of a street, alley or public way. 3. An imaginary line between two buildings on the same lot. The FSD is measured at right angles from the face of the wall. (General IBC Definition)";
}
render() {
this._extractDefinitions();
let definitionOptionsHtml = '<option value="">-- Select a Term --</option>';
const sortedKeys = Object.keys(this.allDefinitions).sort();
sortedKeys.forEach(key => {
definitionOptionsHtml += `<option value="${key}" ${this.state.selectedDefinitionKey === key ? 'selected' : ''}>${key}</option>`;
});
return `
<div class="definitions-module">
<h2>Chapter 2: Definitions</h2>
<div class="form-section">
<h3>Find Definitions <i class="fas fa-info-circle" title="Search or select predefined terms."></i></h3>
<p>This chapter contains definitions of terms used throughout the code. Select a term or search.</p>
<div class="form-group">
<label for="definition-select">Select a Defined Term (Illustrative):</label>
<select id="definition-select">${definitionOptionsHtml}</select>
</div>
<div class="form-group">
<label for="definition-search">Search Definitions:</label>
<input type="text" id="definition-search" value="${this.state.searchTerm}" placeholder="Type to filter terms...">
</div>
</div>
<div class="form-section">
<h3>Term Details <i class="fas fa-info-circle" title="Displays the selected definition or search results."></i></h3>
<div id="definition-display-area" class="compliance-message info" style="margin-top:1rem; display: ${this.state.selectedDefinitionKey ? 'flex' : 'none'};">
<!-- Content dynamically set by JS -->
</div>
<div id="search-results-area" style="margin-top:1rem;">
${this.state.filteredDefinitions.length > 0 ? '<h4>Search Results:</h4><ul>' + this.state.filteredDefinitions.map(def => `<li><strong>${def.key}:</strong> ${def.value}</li>`).join('') + '</ul>' : (this.state.searchTerm ? '<p>No matching terms found.</p>' : '')}
</div>
</div>
${Object.keys(this.allDefinitions).length === 0 && G_CODE_DATA ? '<p class="compliance-message warning"><i class="fas fa-exclamation-triangle"></i> No definitions extracted from the loaded code data. Check JSON structure.</p>' : ''}
${!G_CODE_DATA ? '<p class="compliance-message error"><i class="fas fa-times-circle"></i> Code Data not loaded. Definitions cannot be displayed.</p>' : ''}
</div>
`;
}
initEvents() {
const searchInput = document.getElementById('definition-search');
const selectDropdown = document.getElementById('definition-select');
searchInput?.addEventListener('input', (e) => {
this.state.searchTerm = e.target.value.toLowerCase();
this._filterDefinitions();
this._renderSearchResults();
});
selectDropdown?.addEventListener('change', (e) => {
this.state.selectedDefinitionKey = e.target.value;
this._displaySelectedDefinition();
});
if (this.state.selectedDefinitionKey) this._displaySelectedDefinition();
if (this.state.searchTerm) {
this._filterDefinitions();
this._renderSearchResults();
}
}
_filterDefinitions() {
if (!this.state.searchTerm) {
this.state.filteredDefinitions = [];
return;
}
this.state.filteredDefinitions = Object.entries(this.allDefinitions)
.filter(([key, value]) => key.toLowerCase().includes(this.state.searchTerm) || value.toLowerCase().includes(this.state.searchTerm))
.map(([key, value]) => ({ key, value }));
}
_renderSearchResults() {
const searchResultsArea = document.getElementById('search-results-area');
if(searchResultsArea) {
searchResultsArea.innerHTML = `
${this.state.filteredDefinitions.length > 0 ? '<h4>Search Results:</h4><ul>' + this.state.filteredDefinitions.map(def => `<li><strong>${def.key}:</strong> ${def.value}</li>`).join('') + '</ul>' : (this.state.searchTerm ? '<p>No matching terms found.</p>' : '')}
`;
}
}
_displaySelectedDefinition() {
const displayArea = document.getElementById('definition-display-area');
if(!displayArea) return;
if (this.state.selectedDefinitionKey && this.allDefinitions[this.state.selectedDefinitionKey]) {
displayArea.innerHTML = `
<i class="fas fa-info-circle"></i>
<div>
<h4>${this.state.selectedDefinitionKey}</h4>
<p>${this.allDefinitions[this.state.selectedDefinitionKey]}</p>
</div>
`;
displayArea.style.display = 'flex';
} else {
displayArea.style.display = 'none';
}
}
saveData() {
const data = { searchTerm: this.state.searchTerm, selectedDefinitionKey: this.state.selectedDefinitionKey };
this.app.updateProjectData(this.chapterId, data);
return data;
}
loadData(data) {
this.state = { ...this.state, ...data };
const searchInput = document.getElementById('definition-search');
if (searchInput) {
searchInput.value = this.state.searchTerm;
const selectDropdown = document.getElementById('definition-select');
if (selectDropdown) selectDropdown.value = this.state.selectedDefinitionKey;
this._extractDefinitions();
this._filterDefinitions();
this._renderSearchResults();
this._displaySelectedDefinition();
}
}
}
class OccupancyModule {
constructor(app) {
this.app = app;
this.chapterId = 'occupancy';
this.state = {
occupancyGroupForContext: '',
functionOfSpace: '',
area: 0,
areaUnit: '',
occupantLoadFactor: 'N/A',
calculatedOccupantLoad: 0
};
}
render() {
const codeTableOccupantLoad = G_CODE_DATA?.tables?.occupantLoadFactors?.data || {};
let functionOptionsHtml = '<option value="">-- Select Function --</option>';
Object.keys(codeTableOccupantLoad).sort().forEach(funcKey => {
const funcData = codeTableOccupantLoad[funcKey];
const displayName = funcKey.replace(/_/g, ' ');
functionOptionsHtml += `<option value="${funcKey}" ${this.state.functionOfSpace === funcKey ? 'selected' : ''}>${displayName} (${funcData.factor} ${funcData.unit})</option>`;
});
return `
<div class="occupancy-module">
<h2>Chapter 3: Use and Occupancy Classification</h2>
<div class="form-section">
<h3>Occupant Load Calculation <i class="fas fa-info-circle" title="Determine occupant load based on space function and area (IBC Table 1004.5)."></i></h3>
<p>Determine the Occupant Load based on space use and area (IBC Table 1004.5). The primary occupancy classification is set in Chapter 5.</p>
<div class="form-group">
<label for="function-of-space">Specific Function of Space for Occupant Load Calculation:</label>
<select id="function-of-space" ${!G_CODE_DATA ? 'disabled' : ''}>${functionOptionsHtml}</select>
</div>
<div class="form-group">
<label for="area" id="area-label">Area (sq ft) or Count:</label>
<input type="number" id="area" min="0" step="any" value="${this.state.area}">
</div>
<div class="form-group">
<label for="occupant-load-factor">Occupant Load Factor:</label>
<input type="text" id="occupant-load-factor" value="${this.state.occupantLoadFactor}" readonly class="form-control-plaintext">
<small id="olf-unit-display">Unit: ${this.state.areaUnit}</small>
</div>
<div class="form-row">
<div class="form-group">
<button id="calculate-load" ${!G_CODE_DATA ? 'disabled' : ''}><i class="fas fa-calculator"></i> Calculate Occupant Load</button>
</div>
<div class="form-group">
<button id="reset-fields" class="secondary"><i class="fas fa-undo"></i> Reset</button>
</div>
</div>
</div>
<div class="form-section" id="occupancy-results-section" style="display: ${this.state.calculatedOccupantLoad > 0 ? 'block' : 'none'};">
<h3>Results <i class="fas fa-info-circle" title="Calculated occupant load for the specified function and area."></i></h3>
<div class="results" id="occupancy-results">
<p>Calculated Occupant Load for this function/area: <strong id="calculated-load">${this.state.calculatedOccupantLoad}</strong></p>
<p><small>This specific calculation may be for a single space. The overall project Occupancy Group is set in Chapter 5.</small></p>
</div>
</div>
<div class="form-section">
<h3>Reference: Occupant Load Factors <i class="fas fa-info-circle" title="Data from IBC Table 1004.5 as per loaded code data."></i></h3>
<div class="table-container" style="max-height: 300px; overflow-y:auto;">
<table id="occupant-load-factor-table-ref">
<thead><tr><th>Function of Space</th><th>Factor</th><th>Unit</th><th>Notes/Exceptions</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
${!G_CODE_DATA ? '<p class="compliance-message error"><i class="fas fa-times-circle"></i> Code Data not loaded. Calculations disabled.</p>' : ''}
${Object.keys(codeTableOccupantLoad).length === 0 && G_CODE_DATA ? '<p class="compliance-message warning"><i class="fas fa-exclamation-triangle"></i> Occupant Load Factor data missing or empty in JSON.</p>' : ''}
</div>
`;
}
initEvents() {
const functionSelect = document.getElementById('function-of-space');
const areaInput = document.getElementById('area');
const calculateBtn = document.getElementById('calculate-load');
const resetBtn = document.getElementById('reset-fields');
functionSelect?.addEventListener('change', (e) => {
this.state.functionOfSpace = e.target.value;
this.updateLoadFactor();
});
areaInput?.addEventListener('input', (e) => {
this.state.area = parseFloat(e.target.value) || 0;
});
calculateBtn?.addEventListener('click', () => { this.calculateOccupantLoad(); });
resetBtn?.addEventListener('click', () => { this.resetFields(); });
if (this.state.functionOfSpace && functionSelect) {
functionSelect.value = this.state.functionOfSpace;
}
this.updateLoadFactor();
this.populateReferenceTable();
const calculatedLoadEl = document.getElementById('calculated-load');
const occupancyResultsSectionEl = document.getElementById('occupancy-results-section');
if (this.state.calculatedOccupantLoad > 0) {
if(calculatedLoadEl) calculatedLoadEl.textContent = this.state.calculatedOccupantLoad;
if(occupancyResultsSectionEl) occupancyResultsSectionEl.style.display = 'block';
} else {
if(occupancyResultsSectionEl) occupancyResultsSectionEl.style.display = 'none';
}
}
populateReferenceTable() {
const tableBody = document.getElementById('occupant-load-factor-table-ref')?.querySelector('tbody');
if (!tableBody || !G_CODE_DATA?.tables?.occupantLoadFactors?.data) return;
tableBody.innerHTML = '';
const factors = G_CODE_DATA.tables.occupantLoadFactors.data;
for (const funcKey in factors) {
const funcData = factors[funcKey];
const displayName = funcKey.replace(/_/g, ' ');
const row = tableBody.insertRow();
row.insertCell().textContent = displayName;
row.insertCell().textContent = funcData.factor;
row.insertCell().textContent = funcData.unit;
let notesAndExceptions = funcData.notes || '';
if (funcData.exceptions && Array.isArray(funcData.exceptions)) {
notesAndExceptions += (notesAndExceptions ? '; ' : '') + 'Exceptions: ' + funcData.exceptions.join(', ');
} else if (funcData.exceptions) {
notesAndExceptions += (notesAndExceptions ? '; ' : '') + 'Exceptions: ' + funcData.exceptions;
}
row.insertCell().textContent = notesAndExceptions;
}
}
updateLoadFactor() {
const factorsData = G_CODE_DATA?.tables?.occupantLoadFactors?.data[this.state.functionOfSpace];
const factorInputEl = document.getElementById('occupant-load-factor');
const unitDisplayEl = document.getElementById('olf-unit-display');
const areaLabelEl = document.getElementById('area-label');
if (factorsData) {
this.state.occupantLoadFactor = factorsData.factor;
this.state.areaUnit = factorsData.unit;
if(factorInputEl) factorInputEl.value = this.state.occupantLoadFactor;
if(unitDisplayEl) unitDisplayEl.textContent = `Unit: ${this.state.areaUnit}`;
if(areaLabelEl) areaLabelEl.textContent = factorsData.factor === "actual" ? "Count (e.g., number of seats):" : `Area (${this.state.areaUnit.replace(/_per_occupant$/, '').replace('_', ' ')}) :`;
const keyParts = this.state.functionOfSpace.split('_');
const potentialOccGroup = keyParts[0].toUpperCase();
if (G_CODE_DATA?.tables?.occupancyClassifications?.data?.[potentialOccGroup]) {
this.state.occupancyGroupForContext = potentialOccGroup;
} else {
if (potentialOccGroup === 'FACTORYINDUSTRIAL') this.state.occupancyGroupForContext = 'F';
else if (potentialOccGroup === 'INSTITUTIONAL') this.state.occupancyGroupForContext = 'I';
else if (potentialOccGroup === 'MERCANTILE') this.state.occupancyGroupForContext = 'M';
else if (potentialOccGroup === 'RESIDENTIAL') this.state.occupancyGroupForContext = 'R';
else if (potentialOccGroup === 'STORAGE') this.state.occupancyGroupForContext = 'S';
else this.state.occupancyGroupForContext = '';
}
} else {
this.state.occupantLoadFactor = 'N/A';
this.state.areaUnit = '';
if(factorInputEl) factorInputEl.value = 'N/A';
if(unitDisplayEl) unitDisplayEl.textContent = 'Unit:';
if(areaLabelEl) areaLabelEl.textContent = 'Area (sq ft) or Count:';
this.state.occupancyGroupForContext = '';
}
}
calculateOccupantLoad() {
if (this.state.occupantLoadFactor === 'N/A' || !this.state.functionOfSpace) {
this.app.addOutputMessage('Ch 3: Invalid function or load factor. Select a function.', 'error');
return;
}
const areaVal = this.state.area;
const occupancyResultsSectionEl = document.getElementById('occupancy-results-section');
if (areaVal <= 0 && this.state.occupantLoadFactor !== "actual") {
this.app.addOutputMessage('Ch 3: Area/Count must be greater than 0.', 'error');
this.state.calculatedOccupantLoad = 0;
if(occupancyResultsSectionEl) occupancyResultsSectionEl.style.display = 'none';
return;
}
if (this.state.occupantLoadFactor === "actual") {
this.state.calculatedOccupantLoad = Math.ceil(areaVal);
} else {
const factorNum = parseFloat(this.state.occupantLoadFactor);
if(isNaN(factorNum) || factorNum <=0) {
this.app.addOutputMessage('Ch 3: Invalid Occupant Load Factor value in code data.', 'error');
this.state.calculatedOccupantLoad = 0;
if(occupancyResultsSectionEl) occupancyResultsSectionEl.style.display = 'none';
return;
}
this.state.calculatedOccupantLoad = Math.ceil(areaVal / factorNum);
}
const calculatedLoadEl = document.getElementById('calculated-load');
if(calculatedLoadEl) calculatedLoadEl.textContent = this.state.calculatedOccupantLoad;
if(occupancyResultsSectionEl) occupancyResultsSectionEl.style.display = 'block';
this.app.addOutputMessage(
`Ch 3: Function '${this.state.functionOfSpace.replace(/_/g, ' ')}', Area/Count: ${areaVal}, Factor: ${this.state.occupantLoadFactor} ${this.state.areaUnit}. Calculated OL: ${this.state.calculatedOccupantLoad}`,
'success'
);
this.saveData();
}
resetFields() {
const codeTableOccupantLoad = G_CODE_DATA?.tables?.occupantLoadFactors?.data || {};
this.state.functionOfSpace = Object.keys(codeTableOccupantLoad)[0] || '';
this.state.area = 0;
this.state.calculatedOccupantLoad = 0;
const functionSelect = document.getElementById('function-of-space');
if(functionSelect) functionSelect.value = this.state.functionOfSpace;
const areaInput = document.getElementById('area');
if(areaInput) areaInput.value = this.state.area;
const calculatedLoadEl = document.getElementById('calculated-load');
if(calculatedLoadEl) calculatedLoadEl.textContent = '0';
const occupancyResultsSectionEl = document.getElementById('occupancy-results-section');
if(occupancyResultsSectionEl) occupancyResultsSectionEl.style.display = 'none';
this.updateLoadFactor();
this.app.addOutputMessage('Ch 3: Occupancy calculation fields reset.', 'info');
this.saveData();
}
saveData() {
const data = { ...this.state };
this.app.updateProjectData(this.chapterId, data);
const projectSummary = this.app.getProjectData().data.projectSummary || {};
if (this.state.calculatedOccupantLoad > 0) {
this.app.updateProjectData('projectSummary', {
...projectSummary,
mainFunctionSpaceOccupantLoad: this.state.calculatedOccupantLoad,
});
}
return data;
}
loadData(data) {
this.state = { ...this.state, ...data };
const functionSelect = document.getElementById('function-of-space');
if (functionSelect) {
functionSelect.value = this.state.functionOfSpace;
const areaInput = document.getElementById('area');
if (areaInput) areaInput.value = this.state.area;
this.updateLoadFactor();
const calculatedLoadEl = document.getElementById('calculated-load');
const occupancyResultsSectionEl = document.getElementById('occupancy-results-section');
if (this.state.calculatedOccupantLoad > 0) {
if(calculatedLoadEl) calculatedLoadEl.textContent = this.state.calculatedOccupantLoad;
if(occupancyResultsSectionEl) occupancyResultsSectionEl.style.display = 'block';
} else {
if(calculatedLoadEl) calculatedLoadEl.textContent = '0';
if(occupancyResultsSectionEl) occupancyResultsSectionEl.style.display = 'none';
}
}
}
}
class SpecialUsesModule {
constructor(app) {
this.app = app;
this.chapterId = 'special-uses';
this.state = {
hasCoveredMall: false,
hasHighRiseProvisions: false,
hasAtrium: false,
notes: ""
};
}
render() {
return `
<div class="special-uses-module">
<h2>Chapter 4: Special Detailed Requirements Based on Use and Occupancy</h2>
<div class="form-section">
<h3>Special Use Identification <i class="fas fa-info-circle" title="Identify if specific special use provisions apply to the project."></i></h3>
<p>This chapter addresses buildings with special uses that require provisions beyond general requirements. Examples: covered malls (IBC 402), high-rise buildings (IBC 403), atriums (IBC 404).</p>
<p><em>This module is a placeholder for detailed calculations. Below are flags for project tracking.</em></p>
<div class="form-group">
<label for="has-covered-mall">Project involves a Covered Mall Building (IBC 402)?</label>
<select id="has-covered-mall">
<option value="false" ${!this.state.hasCoveredMall ? 'selected' : ''}>No</option>
<option value="true" ${this.state.hasCoveredMall ? 'selected' : ''}>Yes</option>
</select>
</div>
<div class="form-group">
<label for="has-high-rise">Project classifies as a High-Rise Building (IBC 403)?</label>
<select id="has-high-rise">
<option value="false" ${!this.state.hasHighRiseProvisions ? 'selected' : ''}>No</option>
<option value="true" ${this.state.hasHighRiseProvisions ? 'selected' : ''}>Yes</option>
</select>
<small>Typically, occupied floor > 75 ft above lowest fire dept vehicle access.</small>
</div>
<div class="form-group">
<label for="has-atrium">Project includes an Atrium (IBC 404)?</label>
<select id="has-atrium">
<option value="false" ${!this.state.hasAtrium ? 'selected' : ''}>No</option>
<option value="true" ${this.state.hasAtrium ? 'selected' : ''}>Yes</option>
</select>
</div>
<div class="form-group">
<label for="special-uses-notes">Notes on Special Uses:</label>
<textarea id="special-uses-notes" rows="3" placeholder="e.g., Specific sections of Ch.4 applicable...">${this.state.notes}</textarea>
</div>
<button id="save-special-uses-data"><i class="fas fa-save"></i> Save Special Uses Data</button>
</div>
</div>
`;
}
initEvents() {
const coveredMallEl = document.getElementById('has-covered-mall');
const highRiseEl = document.getElementById('has-high-rise');
const atriumEl = document.getElementById('has-atrium');
const notesEl = document.getElementById('special-uses-notes');
const saveBtn = document.getElementById('save-special-uses-data');
if (coveredMallEl) coveredMallEl.value = this.state.hasCoveredMall.toString();
if (highRiseEl) highRiseEl.value = this.state.hasHighRiseProvisions.toString();
if (atriumEl) atriumEl.value = this.state.hasAtrium.toString();
if (notesEl) notesEl.value = this.state.notes;
coveredMallEl?.addEventListener('change', (e) => this.state.hasCoveredMall = e.target.value === 'true');
highRiseEl?.addEventListener('change', (e) => this.state.hasHighRiseProvisions = e.target.value === 'true');
atriumEl?.addEventListener('change', (e) => this.state.hasAtrium = e.target.value === 'true');
notesEl?.addEventListener('input', (e) => this.state.notes = e.target.value);
saveBtn?.addEventListener('click', () => {
this.saveData();
this.app.addOutputMessage('Ch 4: Special Uses data saved.', 'success');
});
}
saveData() {
const data = { ...this.state };
this.app.updateProjectData(this.chapterId, data);
this.app.updateProjectData('projectSummary', {
...this.app.getProjectData().data.projectSummary,
hasHighRiseProvisions: this.state.hasHighRiseProvisions
});
return data;
}
loadData(data) {
this.state = { ...this.state, ...data };
const coveredMallEl = document.getElementById('has-covered-mall');
if (coveredMallEl) {
coveredMallEl.value = this.state.hasCoveredMall.toString();
const highRiseEl = document.getElementById('has-high-rise');
if (highRiseEl) highRiseEl.value = this.state.hasHighRiseProvisions.toString();
const atriumEl = document.getElementById('has-atrium');
if (atriumEl) atriumEl.value = this.state.hasAtrium.toString();
const notesEl = document.getElementById('special-uses-notes');
if (notesEl) notesEl.value = this.state.notes;
}
}
}
class GeneralBuildingModule {
constructor(app) {
this.app = app;
this.chapterId = 'general-building';
this.state = {
constructionType: '',
occupancyGroup: '',
proposedAreaPerStory: 0,
proposedStories: 1,
proposedHeightFt: 0,
isSprinklered: false,
frontageIncreasePercentage: 0,
allowableAreaPerStory: 0,
allowableStories: 0,
allowableHeightFt: 0,
areaCompliant: null,
storiesCompliant: null,
heightCompliant: null
};
}
_formatValue(val, precision = 0) {
if (val === "NP") return "Not Permitted";
if (val === Number.POSITIVE_INFINITY) return "No Limit";
if (typeof val === "number") return val.toLocaleString(undefined, {maximumFractionDigits: precision});
return "N/A";
}
render() {
const codeDataHA = G_CODE_DATA?.tables?.allowableBuildingHeightsAreas?.data || {};
const constructionTypesData = G_CODE_DATA?.tables?.constructionTypes?.data || {};
let constructionOptions = '<option value="">-- Select --</option>';
const allConstructionKeys = new Set([...Object.keys(constructionTypesData), ...Object.keys(codeDataHA)]);
Array.from(allConstructionKeys).sort().forEach(ct => {
const desc = constructionTypesData[ct]?.description || ct;
constructionOptions += `<option value="${ct}" ${this.state.constructionType === ct ? 'selected' : ''}>${ct} - ${desc}</option>`;
});
const projectSummary = this.app.getProjectData().data.projectSummary || {};
this.state.isSprinklered = projectSummary.isSprinklered === true;
const defaultMaxFrontage = 75;
let maxFrontage = defaultMaxFrontage;
const frontageData = codeDataHA?.[this.state.constructionType]?.[this.state.occupancyGroup]?.frontage_increase;
if (frontageData?.max_percentage) {
maxFrontage = frontageData.max_percentage;
} else if (this.state.constructionType && this.state.occupancyGroup) {
const firstCT = Object.keys(codeDataHA)[0];
const firstOcc = firstCT ? Object.keys(codeDataHA[firstCT]).find(k => !k.startsWith('notes') && k !== 'exceptions') : null;
maxFrontage = firstCT && firstOcc ? (codeDataHA[firstCT]?.[firstOcc]?.frontage_increase?.max_percentage || defaultMaxFrontage) : defaultMaxFrontage;
}
return `
<div class="general-building-module">
<h2>Chapter 5: General Building Heights & Areas</h2>
<div class="form-section">
<h3>Building Parameters <i class="fas fa-info-circle" title="Define the primary construction type, occupancy, and proposed dimensions of the building."></i></h3>
<p>Determine allowable building dimensions (IBC Tables 504.3, 504.4, 506.2).</p>
<div class="form-row">
<div class="form-group">
<label for="construction-type">Construction Type:</label>
<select id="construction-type" ${!G_CODE_DATA ? 'disabled' : ''}>${constructionOptions}</select>
</div>
<div class="form-group">
<label for="primary-occupancy-group">Predominant Occupancy Group:</label>
<select id="primary-occupancy-group" disabled>
<option value="">-- Select Construction Type First --</option>
</select>
<small id="primary-occupancy-group-warning" class="danger-color-text" style="display:none;"></small>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="proposed-area">Proposed Area per Story (sq ft):</label>
<input type="number" id="proposed-area" min="0" step="any" value="${this.state.proposedAreaPerStory}">
</div>
<div class="form-group">
<label for="proposed-stories">Proposed Number of Stories:</label>
<input type="number" id="proposed-stories" min="1" value="${this.state.proposedStories}">
</div>
</div>
<div class="form-group">
<label for="proposed-height-ft">Proposed Building Height (ft):</label>
<input type="number" id="proposed-height-ft" min="0" step="any" value="${this.state.proposedHeightFt}">
</div>
</div>
<div class="form-section">
<h3>System Inputs & Increases <i class="fas fa-info-circle" title="Specify frontage increase percentage and view sprinkler status (from Ch.9/Summary)."></i></h3>
<div class="form-group">
<label for="frontage-increase">Frontage Increase (If, % e.g., 0-${maxFrontage}):</label>
<input type="number" id="frontage-increase" min="0" max="${maxFrontage}" step="any" value="${this.state.frontageIncreasePercentage}">
<small>Calculated per IBC 506.3. Input percentage value.</small>
</div>
<p><em>Sprinkler status is automatically read from Project Summary.</em></p>
<p id="gb-sprinkler-status">Current Sprinkler Status: <strong>${this.state.isSprinklered ? 'Yes (Fully Sprinklered)' : 'No'}</strong></p>
<button id="calculate-heights-areas" ${!G_CODE_DATA ? 'disabled' : ''}><i class="fas fa-ruler-combined"></i> Calculate Allowable Heights & Areas</button>
</div>
<div class="form-section" id="heights-areas-results-section" style="display: ${this.state.allowableAreaPerStory > 0 || this.state.areaCompliant !== null || this.state.allowableHeightFt === "NP" ? 'block' : 'none'};">
<h3>Results <i class="fas fa-info-circle" title="Calculated allowable dimensions and compliance status."></i></h3>
<div id="heights-areas-results">
<p>Allowable Stories: <strong id="allowable-stories">N/A</strong> <span id="stories-compliance"></span></p>
<p>Allowable Height (ft): <strong id="allowable-height-ft">N/A</strong> <span id="height-ft-compliance"></span></p>
<p>Base Allowable Area/Story (sq ft): <strong id="base-allowable-area">N/A</strong></p>
<p>Allowable Area/Story with Increases (sq ft): <strong id="final-allowable-area">N/A</strong> <span id="area-compliance"></span></p>
<div id="ha-notes-exceptions" class="compliance-message warning" style="display:none;"><h4>Notes/Exceptions:</h4></div>
</div>
</div>
${!G_CODE_DATA ? '<p class="compliance-message error"><i class="fas fa-times-circle"></i> Code Data not loaded. Calculations disabled.</p>' : ''}
${G_CODE_DATA && Object.keys(codeDataHA).length === 0 ? '<p class="compliance-message warning"><i class="fas fa-exclamation-triangle"></i> Allowable Heights & Areas data table is missing or empty in JSON.</p>' : ''}
</div>
`;
}
initEvents() {
const constructionSelect = document.getElementById('construction-type');
const occupancySelect = document.getElementById('primary-occupancy-group');
const areaInput = document.getElementById('proposed-area');
const storiesInput = document.getElementById('proposed-stories');
const heightInput = document.getElementById('proposed-height-ft');
const frontageInput = document.getElementById('frontage-increase');
const calculateBtn = document.getElementById('calculate-heights-areas');
if (constructionSelect) constructionSelect.value = this.state.constructionType;
if (areaInput) areaInput.value = this.state.proposedAreaPerStory;
if (storiesInput) storiesInput.value = this.state.proposedStories;
if (heightInput) heightInput.value = this.state.proposedHeightFt;
if (frontageInput)frontageInput.value= this.state.frontageIncreasePercentage;
constructionSelect?.addEventListener('change', e => {
this.state.constructionType = e.target.value;
this.state.occupancyGroup = '';
this._clearResults();
this.populateOccupancyDropdown();
this.saveData();
if (occupancySelect) occupancySelect.focus();
});
occupancySelect?.addEventListener('change', e => {
this.state.occupancyGroup = e.target.value;
this._clearResults();
this.saveData();
});
areaInput?.addEventListener ('input', e => this.state.proposedAreaPerStory = parseFloat(e.target.value) || 0);
storiesInput?.addEventListener ('input', e => this.state.proposedStories = parseInt (e.target.value) || 1);
heightInput?.addEventListener ('input', e => this.state.proposedHeightFt = parseFloat(e.target.value) || 0);
frontageInput?.addEventListener ('input', e => this.state.frontageIncreasePercentage = parseFloat(e.target.value) || 0);
calculateBtn?.addEventListener('click', () => this.calculate());
this.populateOccupancyDropdown();
this.updateSprinklerStatusDisplay();
if (this.state.allowableAreaPerStory > 0 || this.state.areaCompliant !== null || this.state.allowableHeightFt === "NP") {
const limitsData = G_CODE_DATA?.tables?.allowableBuildingHeightsAreas?.data?.[this.state.constructionType]?.[this.state.occupancyGroup];
const baseArea = limitsData?.base?.area_sqft_per_story;
this._displayResults(limitsData, baseArea);
}
}
populateOccupancyDropdown() {
const occupancySelect = document.getElementById('primary-occupancy-group');
if (!occupancySelect) return;
const codeDataHA = G_CODE_DATA?.tables?.allowableBuildingHeightsAreas?.data || {};
const occupancyClassData = G_CODE_DATA?.tables?.occupancyClassifications?.data || {};
const constructionTypeHasData = G_CODE_DATA && this.state.constructionType && codeDataHA[this.state.constructionType];
occupancySelect.disabled = !constructionTypeHasData;
let occupancyOptionsHtml = '';
if (constructionTypeHasData) {
occupancyOptionsHtml = '<option value="">-- Select Occupancy --</option>';
const occupanciesForCT = codeDataHA[this.state.constructionType];
Object.keys(occupanciesForCT)
.filter(og => og !== "exceptions" && !og.startsWith("notes"))
.sort()
.forEach(og => {
const desc = occupancyClassData[og]?.description || og;
let subGroupText = '';
if(occupancyClassData[og]?.subgroups){
const subKeys = Object.keys(occupancyClassData[og].subgroups);
if(!(subKeys.length === 1 && subKeys[0] === og && Object.values(occupancyClassData[og].subgroups)[0].toLowerCase().includes('general'))) {
if (subKeys.length > 0) subGroupText = ` (${subKeys.join('/')})`;
}
} else {
const rootGroupMatch = og.match(/^([A-Z])(-|$)/);
if (rootGroupMatch && occupancyClassData[rootGroupMatch[1]]?.subgroups?.[og]) {
subGroupText = ` (${occupancyClassData[rootGroupMatch[1]].subgroups[og]})`;
}
}
occupancyOptionsHtml += `<option value="${og}" ${this.state.occupancyGroup === og ? 'selected' : ''}>${og} - ${desc}${subGroupText}</option>`;
});
} else if (this.state.constructionType && G_CODE_DATA && !codeDataHA[this.state.constructionType]) {
occupancyOptionsHtml = '<option value="">-- No Occupancy Data for this Construction Type --</option>';
} else {
occupancyOptionsHtml = '<option value="">-- Select Construction Type First --</option>';
}
occupancySelect.innerHTML = occupancyOptionsHtml;
const warningSpan = document.getElementById('primary-occupancy-group-warning');
if (warningSpan) {
const showWarning = this.state.constructionType && G_CODE_DATA && !codeDataHA[this.state.constructionType];
warningSpan.style.display = showWarning ? 'inline' : 'none';
warningSpan.textContent = showWarning ? 'Warning: No H&A data found for this construction type in JSON.' : '';
}
}
updateSprinklerStatusDisplay() {
const projectSummary = this.app.getProjectData().data.projectSummary || {};
this.state.isSprinklered = projectSummary.isSprinklered === true;
const statusEl = document.getElementById('gb-sprinkler-status');
if(statusEl) statusEl.innerHTML = `Current Sprinkler Status: <strong>${this.state.isSprinklered ? 'Yes (Fully Sprinklered)' : 'No'}</strong>`;
}
calculate() {
const { constructionType, occupancyGroup } = this.state;
if (!constructionType || !occupancyGroup) {
this.app.addOutputMessage(
"Ch 5: Please select Construction Type and Occupancy Group.",
"error"
);
this._clearResults();
return;
}
this.updateSprinklerStatusDisplay();
const limitsData =
G_CODE_DATA?.tables?.allowableBuildingHeightsAreas?.data?.[
constructionType
]?.[occupancyGroup];
if (!limitsData || !limitsData.base) {
this.app.addOutputMessage(
`Ch 5: Data not found for ${constructionType}/${occupancyGroup}. Check JSON or select a valid Occupancy Group shown in the dropdown.`,
"error"
);
this._clearResults();
return;
}
const NO_LIMIT = Number.POSITIVE_INFINITY;
const num = (v, int = false) =>
v === "NL"
? NO_LIMIT
: v === "NP"
? "NP"
: int
? parseInt(v, 10) || 0
: parseFloat(v) || 0;
let hBase = num(limitsData.base.height_ft);
let sBase = num(limitsData.base.height_stories, true);
let aBase = num(limitsData.base.area_sqft_per_story);
if ([hBase, sBase, aBase].includes("NP")) {
this.app.addOutputMessage(
`Ch 5: Combination ${constructionType}/${occupancyGroup} is Not Permitted (NP).`,
"error"
);
Object.assign(this.state, {
allowableHeightFt: "NP",
allowableStories: "NP",
allowableAreaPerStory: "NP",
heightCompliant: false,
storiesCompliant: false,
areaCompliant: false,
});
this._displayResults(limitsData, "NP");
this.saveData();
return;
}
this.state.allowableHeightFt = hBase;
this.state.allowableStories = sBase;
let aFinal = aBase;
if (this.state.isSprinklered) {
const inc = limitsData.sprinkler_increase || {};
if (this.state.allowableHeightFt !== NO_LIMIT)
this.state.allowableHeightFt += num(inc.height_ft);
if (this.state.allowableStories !== NO_LIMIT)
this.state.allowableStories += num(inc.stories, true);
if (aFinal !== NO_LIMIT)
aFinal *= num(inc.area_multiplier || 1) ;
}
const fData = limitsData.frontage_increase || {};
const maxF = num(fData.max_percentage || 0);
const appliedF = Math.min(
this.state.frontageIncreasePercentage,
maxF === NO_LIMIT ? Infinity : maxF
);
if (aFinal !== NO_LIMIT) aFinal *= (1 + appliedF / 100);
Object.assign(this.state, {
allowableAreaPerStory: aFinal,
heightCompliant: (hBase === "NP" || this.state.allowableHeightFt === "NP") ? false : this.state.proposedHeightFt <= this.state.allowableHeightFt,
storiesCompliant: (sBase === "NP" || this.state.allowableStories === "NP") ? false : this.state.proposedStories <= this.state.allowableStories,
areaCompliant: (aBase === "NP" || this.state.allowableAreaPerStory === "NP") ? false : this.state.proposedAreaPerStory <= this.state.allowableAreaPerStory,
});
// Update projectSummary for the Summary tab
const projectSummaryUpdates = {
allowableAreaPerStory: this.state.allowableAreaPerStory,
allowableStories: this.state.allowableStories,
allowableHeightFt: this.state.allowableHeightFt,
areaCompliant: this.state.areaCompliant,
storiesCompliant: this.state.storiesCompliant,
heightCompliant: this.state.heightCompliant
};
this.app.updateProjectData('projectSummary', {
...this.app.getProjectData().data.projectSummary,
...projectSummaryUpdates
});
this._displayResults(limitsData, aBase);
this.saveData();
this.app.addOutputMessage(
`Ch 5: ${constructionType}/${occupancyGroup} (Sprk: ${this.state.isSprinklered}, Front%: ${appliedF.toFixed(1)}). Proposed H:${this.state.proposedHeightFt} (Allow: ${this._formatValue(this.state.allowableHeightFt)}), S:${this.state.proposedStories} (Allow: ${this._formatValue(this.state.allowableStories)}), A:${this.state.proposedAreaPerStory} (Allow: ${this._formatValue(this.state.allowableAreaPerStory)}).`,
(this.state.heightCompliant &&
this.state.storiesCompliant &&
this.state.areaCompliant)
? "success"
: "warning"
);
}
_clearResults() {
const resultsSectionDiv = document.getElementById('heights-areas-results-section');
if (resultsSectionDiv) resultsSectionDiv.style.display = 'none';
this.state.allowableAreaPerStory = 0;
this.state.allowableStories = 0;
this.state.allowableHeightFt = 0;
this.state.areaCompliant = null;
this.state.storiesCompliant = null;
this.state.heightCompliant = null;
['allowable-stories', 'stories-compliance', 'allowable-height-ft', 'height-ft-compliance', 'base-allowable-area', 'final-allowable-area', 'area-compliance'].forEach(id => {
const el = document.getElementById(id);
if(el) el.innerHTML = id.includes('compliance') ? '' : 'N/A';
});
const notesEl = document.getElementById('ha-notes-exceptions');
if(notesEl) {
notesEl.style.display = 'none';
notesEl.innerHTML = '<h4>Notes/Exceptions:</h4>';
}
}
_displayResults(limitsData, baseAreaSqftValue) {
const allowableStoriesEl = document.getElementById('allowable-stories');
const storiesComplianceEl = document.getElementById('stories-compliance');
const allowableHeightFtEl = document.getElementById('allowable-height-ft');
const heightFtComplianceEl = document.getElementById('height-ft-compliance');
const baseAllowableAreaEl = document.getElementById('base-allowable-area');
const finalAllowableAreaEl = document.getElementById('final-allowable-area');
const areaComplianceEl = document.getElementById('area-compliance');
const resultsSectionEl = document.getElementById('heights-areas-results-section');
if (allowableStoriesEl) allowableStoriesEl.textContent = this._formatValue(this.state.allowableStories);
this._setComplianceText(storiesComplianceEl, this.state.storiesCompliant);
if (allowableHeightFtEl) allowableHeightFtEl.textContent = this._formatValue(this.state.allowableHeightFt);
this._setComplianceText(heightFtComplianceEl, this.state.heightCompliant);
if (baseAllowableAreaEl) baseAllowableAreaEl.textContent = this._formatValue(baseAreaSqftValue);
if (finalAllowableAreaEl) finalAllowableAreaEl.textContent = this._formatValue(this.state.allowableAreaPerStory);
this._setComplianceText(areaComplianceEl, this.state.areaCompliant);
if (resultsSectionEl) resultsSectionEl.style.display = 'block';
const notesExceptionsEl = document.getElementById('ha-notes-exceptions');
if (notesExceptionsEl) {
notesExceptionsEl.innerHTML = '<h4>Notes/Exceptions:</h4>'; // Reset before adding
let hasContent = false;
const addNote = (text) => {
if (text) {
const p = document.createElement('p');
p.textContent = text;
notesExceptionsEl.appendChild(p);
hasContent = true;
}
};
addNote(limitsData?.base?.notes ? `Base Note: ${limitsData.base.notes}` : null);
addNote(limitsData?.notes ? `Overall Note: ${limitsData.notes}` : null);
if (limitsData?.exceptions && Array.isArray(limitsData.exceptions)) {
limitsData.exceptions.forEach(ex => addNote(`Exception: ${ex}`));
}
const generalTableNotes = G_CODE_DATA?.tables?.allowableBuildingHeightsAreas?.notes;
if (generalTableNotes && Array.isArray(generalTableNotes)) {
generalTableNotes.forEach(gn => addNote(`Table Note: ${gn}`));
}
notesExceptionsEl.style.display = hasContent ? 'block' : 'none';
}
}
_setComplianceText(element, isCompliant) {
if (!element) return;
if (isCompliant === null) {
element.innerHTML = '';
} else if (isCompliant) {
element.innerHTML = ' <i class="fas fa-check-circle success-color-text" title="Compliant"></i>';
} else {
element.innerHTML = ' <i class="fas fa-times-circle danger-color-text" title="Non-Compliant"></i>';
}
}
saveData() {
const dataToSave = {
constructionType: this.state.constructionType,
occupancyGroup: this.state.occupancyGroup,
proposedAreaPerStory: this.state.proposedAreaPerStory,
proposedStories: this.state.proposedStories,
proposedHeightFt: this.state.proposedHeightFt,
frontageIncreasePercentage:this.state.frontageIncreasePercentage
// Do not save calculated values like allowableX or XCompliant here,
// they should be recalculated on load or when needed
};
this.app.updateProjectData(this.chapterId, dataToSave);
this.app.updateProjectData('projectSummary', {
...this.app.getProjectData().data.projectSummary,
constructionType: this.state.constructionType,
primaryOccupancyGroup: this.state.occupancyGroup,
numStories: this.state.proposedStories,
actualHeightFt: this.state.proposedHeightFt,
areaPerStory: this.state.proposedAreaPerStory
});
return dataToSave;
}
loadData(data) {
this.state = { ...this.state, ...data };
// Reset calculated fields before potential recalculation
this.state.allowableAreaPerStory = 0;
this.state.allowableStories = 0;
this.state.allowableHeightFt = 0;
this.state.areaCompliant = null;
this.state.storiesCompliant = null;
this.state.heightCompliant = null;
const constructionTypeSelect = document.getElementById('construction-type');
if (!constructionTypeSelect) return;
constructionTypeSelect.value = this.state.constructionType;
const proposedAreaEl = document.getElementById('proposed-area');
if (proposedAreaEl) proposedAreaEl.value = this.state.proposedAreaPerStory;
const proposedStoriesEl = document.getElementById('proposed-stories');
if (proposedStoriesEl) proposedStoriesEl.value = this.state.proposedStories;
const proposedHeightFtEl = document.getElementById('proposed-height-ft');
if (proposedHeightFtEl) proposedHeightFtEl.value = this.state.proposedHeightFt;
const frontageIncreaseEl = document.getElementById('frontage-increase');
if (frontageIncreaseEl) frontageIncreaseEl.value = this.state.frontageIncreasePercentage;
this.populateOccupancyDropdown(); // This will also set the occupancy group dropdown value
this.updateSprinklerStatusDisplay();
if (this.state.constructionType && this.state.occupancyGroup) {
this.calculate(); // Recalculate to populate results and projectSummary
} else {
this._clearResults();
}
}
}
class TypesOfConstructionModule {
constructor(app) {
this.app = app;
this.chapterId = 'types-of-construction';
this.state = {
selectedConstructionType: "",
constructionTypeDefinitions: null,
fireResistanceRatings: null
};
}
render() {
if (!this.state.selectedConstructionType) {
this.state.selectedConstructionType = this.app.getProjectData().data.projectSummary?.constructionType || "";
}
const constructionTypesData = G_CODE_DATA?.tables?.constructionTypes?.data || {};
const codeDataHA = G_CODE_DATA?.tables?.allowableBuildingHeightsAreas?.data || {};
let constructionOptions = '<option value="">-- Select Construction Type --</option>';
const allConstructionKeys = new Set([...Object.keys(constructionTypesData), ...Object.keys(codeDataHA)]);
Array.from(allConstructionKeys).sort().forEach(ct => {
const desc = constructionTypesData[ct]?.description || ct;
constructionOptions += `<option value="${ct}" ${this.state.selectedConstructionType === ct ? 'selected' : ''}>Type ${ct} - ${desc}</option>`;
});
let definitionsHtml = "<p>Select a construction type to see its general definition.</p>";
if (this.state.selectedConstructionType && this.state.constructionTypeDefinitions) {
const def = this.state.constructionTypeDefinitions;
definitionsHtml = `<h4>Definition of Type ${this.state.selectedConstructionType}</h4>
<p><strong>Description:</strong> ${def.description || 'N/A'}</p>
<p><strong>Combustible:</strong> ${def.combustible !== undefined ? def.combustible : 'N/A'}</p>
<p><strong>General Fire Resistance:</strong> ${def.fireResistanceRating || 'N/A'}</p>
<p><strong>Typical Materials:</strong> ${def.typicalMaterials || 'N/A'}</p>`;
} else if (this.state.selectedConstructionType && !this.state.constructionTypeDefinitions && G_CODE_DATA) {
definitionsHtml = `<p class="compliance-message warning"><i class="fas fa-exclamation-triangle"></i> General definition data not found for Type ${this.state.selectedConstructionType} in the 'constructionTypes' table.</p>`;
}
let ratingsHtml = "<p>Fire-resistance ratings for structural elements (from IBC Table 601 data) will appear here.</p>";
if (this.state.selectedConstructionType && this.state.fireResistanceRatings) {
ratingsHtml = `<h4>Fire-Resistance Ratings for Type ${this.state.selectedConstructionType} (Table 601)</h4><ul>`;
for (const element in this.state.fireResistanceRatings) {
const detail = this.state.fireResistanceRatings[element];
const elName = element.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
let ratingText = typeof detail.rating_hours === 'number' ? `${detail.rating_hours} hr` : detail.rating_hours;
if (detail.details_ref) ratingText += ` (See ${detail.details_ref})`;
ratingsHtml += `<li><strong>${elName}:</strong> ${ratingText} ${detail.notes ? `(${detail.notes})` : ''}</li>`;
}
ratingsHtml += "</ul>";
if(this.state.fireResistanceRatings?.nonbearing_walls_exterior?.details_ref === "IBC Table 602"){
ratingsHtml += `<p><small>Note: Nonbearing exterior wall ratings depend on fire separation distance (see Ch. 14 or actual IBC Table 602).</small></p>`;
}
} else if (this.state.selectedConstructionType && !this.state.fireResistanceRatings && G_CODE_DATA) {
ratingsHtml = `<p class="compliance-message warning"><i class="fas fa-exclamation-triangle"></i> Fire-resistance rating data not found for Type ${this.state.selectedConstructionType} in the 'fireResistanceRatingsStructuralElements' table.</p>`;
}
return `
<div class="types-of-construction-module">
<h2>Chapter 6: Types of Construction</h2>
<div class="form-section">
<h3>Construction Type Details <i class="fas fa-info-circle" title="Review definitions and fire-resistance ratings for selected construction types."></i></h3>
<p>This chapter defines construction types based on combustibility and fire resistance. This impacts allowable heights/areas (Ch. 5) and fire-resistance requirements (Ch. 7).</p>
<div class="form-group">
<label for="toc-construction-type">Select Construction Type to Review:</label>
<select id="toc-construction-type" ${!G_CODE_DATA ? 'disabled' : ''}>${constructionOptions}</select>
<small>Project construction type is typically set in Chapter 5 or loaded from project data.</small>
</div>
<div id="construction-type-definition-display" style="margin-top: 1.5rem;">
${definitionsHtml}
</div>
<div id="construction-type-ratings-display" style="margin-top: 1.5rem;">
${ratingsHtml}
</div>
</div>
${!G_CODE_DATA ? '<p class="compliance-message error"><i class="fas fa-times-circle"></i> Code Data not loaded. This module requires code data.</p>' : ''}
${G_CODE_DATA && (!G_CODE_DATA.tables?.constructionTypes || !G_CODE_DATA.tables?.fireResistanceRatingsStructuralElements) ? '<p class="compliance-message warning"><i class="fas fa-exclamation-triangle"></i> One or more required data tables (constructionTypes, fireResistanceRatingsStructuralElements) are missing from the JSON.</p>' : ''}
</div>
`;
}
initEvents() {
const constructionTypeSelect = document.getElementById('toc-construction-type');
constructionTypeSelect?.addEventListener('change', (e) => {
this.state.selectedConstructionType = e.target.value;
this._fetchDetails();
this._updateDisplayAreas();
});
if (this.state.selectedConstructionType) {
if(constructionTypeSelect) constructionTypeSelect.value = this.state.selectedConstructionType;
this._fetchDetails();
this._updateDisplayAreas();
}
}
_fetchDetails() {
this.state.constructionTypeDefinitions = null;
this.state.fireResistanceRatings = null;
if (!G_CODE_DATA || !this.state.selectedConstructionType) return;
if (G_CODE_DATA.tables?.constructionTypes?.data) {
this.state.constructionTypeDefinitions = G_CODE_DATA.tables.constructionTypes.data[this.state.selectedConstructionType] || null;
}
if (G_CODE_DATA.tables?.fireResistanceRatingsStructuralElements?.data) {
this.state.fireResistanceRatings = G_CODE_DATA.tables.fireResistanceRatingsStructuralElements.data[this.state.selectedConstructionType] || null;
}
this.saveData();
}
_updateDisplayAreas() {
let definitionsHtml = "<p>Select a construction type to see its general definition.</p>";
if (this.state.selectedConstructionType) {
if (this.state.constructionTypeDefinitions) {
const def = this.state.constructionTypeDefinitions;
definitionsHtml = `<h4>Definition of Type ${this.state.selectedConstructionType}</h4>
<p><strong>Description:</strong> ${def.description || 'N/A'}</p>
<p><strong>Combustible:</strong> ${def.combustible !== undefined ? def.combustible : 'N/A'}</p>
<p><strong>General Fire Resistance:</strong> ${def.fireResistanceRating || 'N/A'}</p>
<p><strong>Typical Materials:</strong> ${def.typicalMaterials || 'N/A'}</p>`;
} else if (G_CODE_DATA?.tables?.constructionTypes) {
definitionsHtml = `<p class="compliance-message warning"><i class="fas fa-exclamation-triangle"></i> General definition data not found for Type ${this.state.selectedConstructionType} in the 'constructionTypes' table.</p>`;
}
}
let ratingsHtml = "<p>Fire-resistance ratings for structural elements (from IBC Table 601 data) will appear here.</p>";
if (this.state.selectedConstructionType) {
if (this.state.fireResistanceRatings) {
ratingsHtml = `<h4>Fire-Resistance Ratings for Type ${this.state.selectedConstructionType} (Table 601)</h4><ul>`;
for (const element in this.state.fireResistanceRatings) {
const detail = this.state.fireResistanceRatings[element];
const elName = element.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
let ratingText = typeof detail.rating_hours === 'number' ? `${detail.rating_hours} hr` : detail.rating_hours;
if (detail.details_ref) ratingText += ` (See ${detail.details_ref})`;
ratingsHtml += `<li><strong>${elName}:</strong> ${ratingText} ${detail.notes ? `(${detail.notes})` : ''}</li>`;
}
ratingsHtml += "</ul>";
if (this.state.fireResistanceRatings?.nonbearing_walls_exterior?.details_ref === "IBC Table 602") {
ratingsHtml += `<p><small>Note: Nonbearing exterior wall ratings depend on fire separation distance (see Ch. 14 or actual IBC Table 602).</small></p>`;
}
} else if (G_CODE_DATA?.tables?.fireResistanceRatingsStructuralElements) {
ratingsHtml = `<p class="compliance-message warning"><i class="fas fa-exclamation-triangle"></i> Fire-resistance rating data not found for Type ${this.state.selectedConstructionType} in the 'fireResistanceRatingsStructuralElements' table.</p>`;
}
}
const defDisplay = document.getElementById('construction-type-definition-display');
if (defDisplay) defDisplay.innerHTML = definitionsHtml;
const ratDisplay = document.getElementById('construction-type-ratings-display');
if (ratDisplay) ratDisplay.innerHTML = ratingsHtml;
}
saveData() {
const data = { selectedConstructionType: this.state.selectedConstructionType };
this.app.updateProjectData(this.chapterId, data);
return data;
}
loadData(data) {
this.state.selectedConstructionType = data.selectedConstructionType || this.app.getProjectData().data.projectSummary?.constructionType || "";
const projectConstructionType = this.app.getProjectData().data.projectSummary?.constructionType;
if (projectConstructionType && projectConstructionType !== this.state.selectedConstructionType) {
this.state.selectedConstructionType = projectConstructionType;
}
const constructionTypeSelect = document.getElementById('toc-construction-type');
if (!constructionTypeSelect) return;
constructionTypeSelect.value = this.state.selectedConstructionType;
this._fetchDetails();
this._updateDisplayAreas();
}
}
class FireResistanceModule {
constructor(app) {
this.app = app;
this.chapterId = 'fire-resistance';
this.state = {
projectConstructionType: this.app.getProjectData().data.projectSummary?.constructionType || "",
selectedBuildingElement: "",
requiredRating: null, // This will store the object { rating_hours: X, notes: "...", details_ref: "..." }
elementNotes: ""
};
}
render() {
const projectSummary = this.app.getProjectData().data.projectSummary;
this.state.projectConstructionType = projectSummary?.constructionType || this.state.projectConstructionType;
let elementOptionsHtml = '<option value="">-- Select Building Element --</option>';
const ratingsTable = G_CODE_DATA?.tables?.fireResistanceRatingsStructuralElements?.data;
const elementsForType = ratingsTable?.[this.state.projectConstructionType];
if (elementsForType) {
Object.keys(elementsForType).sort().forEach(elemKey => {
const displayName = elemKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
if(!(elemKey === "nonbearing_walls_exterior" && elementsForType[elemKey].details_ref === "IBC Table 602")){
elementOptionsHtml += `<option value="${elemKey}" ${this.state.selectedBuildingElement === elemKey ? 'selected' : ''}>${displayName}</option>`;
}
});
} else if (this.state.projectConstructionType && G_CODE_DATA && !elementsForType) {
elementOptionsHtml = '<option value="">-- No Rating Data for this Construction Type --</option>';
}
let resultHtml = this._getResultHtml();
return `
<div class="fire-resistance-module">
<h2>Chapter 7: Fire and Smoke Protection Features</h2>
<div class="form-section">
<h3>Building Element Fire Resistance (Table 601) <i class="fas fa-info-circle" title="Determine fire-resistance ratings for building elements based on construction type (IBC Table 601)."></i></h3>
<p>Covers fire-resistance of building elements (IBC Table 601), fire walls, barriers, partitions, smoke barriers, shaft enclosures.</p>
<div class="form-group">
<label>Project Construction Type (from Ch.5/Summary):</label>
<input type="text" value="${this.state.projectConstructionType || 'Not Set'}" readonly class="form-control-plaintext">
${!this.state.projectConstructionType ? '<small class="danger-color-text">Set in Chapter 5/6 first.</small>' : ''}
</div>
<div class="form-group">
<label for="fr-building-element">Select Building Element (from Table 601 data):</label>
<select id="fr-building-element" ${!this.state.projectConstructionType || !G_CODE_DATA || !elementsForType ? 'disabled' : ''}>
${elementOptionsHtml}
</select>
${this.state.projectConstructionType && G_CODE_DATA && !elementsForType ? '<small class="danger-color-text">Warning: No fire resistance data found for this construction type in JSON.</small>' : ''}
</div>
</div>
<div class="form-section">
<h3>Required Rating & Notes <i class="fas fa-info-circle" title="Displays the required fire-resistance rating and allows for notes."></i></h3>
<div id="fire-resistance-result" style="margin-top: 1.5rem;">${resultHtml}</div>
<div class="form-group" style="margin-top:1rem;">
<label for="fr-element-notes">Notes for this element:</label>
<textarea id="fr-element-notes" rows="3" placeholder="e.g., Assembly UL number, exceptions...">${this.state.elementNotes}</textarea>
</div>
<button id="save-fr-notes" ${!G_CODE_DATA ? 'disabled' : ''}><i class="fas fa-save"></i> Save Notes</button>
</div>
${!G_CODE_DATA ? '<p class="compliance-message error"><i class="fas fa-times-circle"></i> Code Data not loaded. Module disabled.</p>' : ''}
${G_CODE_DATA && !ratingsTable ? '<p class="compliance-message warning"><i class="fas fa-exclamation-triangle"></i> Fire Resistance Ratings data table is missing from JSON.</p>' : ''}
</div>
`;
}
_getResultHtml() {
if (!this.state.requiredRating) return "";
const ratingVal = this.state.requiredRating.rating_hours;
const ratingDisplay = typeof ratingVal === 'number' ? `${ratingVal} hours` : ratingVal;
const elementName = this.state.selectedBuildingElement.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
return `
<div class="compliance-message info">
<i class="fas fa-info-circle"></i>
<div>
<h4>Required Fire-Resistance Rating (IBC Table 601 data):</h4>
<p>For <strong>${elementName}</strong> in Type <strong>${this.state.projectConstructionType}</strong> construction:</p>
<p><strong>Rating: ${ratingDisplay}</strong></p>
${this.state.requiredRating.notes ? `<p><small>Notes: ${this.state.requiredRating.notes}</small></p>` : ''}
${this.state.requiredRating.details_ref && this.state.requiredRating.details_ref !== "IBC Table 602" ? `<p><small>Details: Refer to ${this.state.requiredRating.details_ref}</small></p>` : ''}
</div>
</div>
`;
}
initEvents() {
this.state.projectConstructionType = this.app.getProjectData().data.projectSummary?.constructionType || this.state.projectConstructionType;
const elementSelect = document.getElementById('fr-building-element');
const notesInput = document.getElementById('fr-element-notes');
const saveButton = document.getElementById('save-fr-notes');
elementSelect?.addEventListener('change', (e) => {
this.state.selectedBuildingElement = e.target.value;
this._calculateRatingAndUpdateDisplay();
});
notesInput?.addEventListener('input', (e) => {
this.state.elementNotes = e.target.value;
});
saveButton?.addEventListener('click', () => {
this.saveData();
this.app.addOutputMessage('Ch 7: Fire resistance notes saved for current selection.', 'success');
});
if (this.state.selectedBuildingElement && elementSelect) {
elementSelect.value = this.state.selectedBuildingElement;
}
if (notesInput) notesInput.value = this.state.elementNotes;
this._calculateRatingAndUpdateDisplay(); // Call this to display initial rating if state is pre-loaded
}
_calculateRatingAndUpdateDisplay() {
this.state.requiredRating = null;
const ratingsTable = G_CODE_DATA?.tables?.fireResistanceRatingsStructuralElements?.data;
if (ratingsTable && this.state.projectConstructionType && this.state.selectedBuildingElement) {
this.state.requiredRating = ratingsTable[this.state.projectConstructionType]?.[this.state.selectedBuildingElement] || null;
}
const resultDiv = document.getElementById('fire-resistance-result');
if (resultDiv) {
resultDiv.innerHTML = this._getResultHtml();
}
}
saveData() {
// Save only user-input or selected state, not the derived 'requiredRating' object
const data = {
selectedBuildingElement: this.state.selectedBuildingElement,
elementNotes: this.state.elementNotes
};
this.app.updateProjectData(this.chapterId, data);
return data;
}
loadData(data) {
this.state.selectedBuildingElement = data.selectedBuildingElement || "";
this.state.elementNotes = data.elementNotes || "";
this.state.projectConstructionType = this.app.getProjectData().data.projectSummary?.constructionType || "";
// `requiredRating` will be re-calculated in initEvents/_calculateRatingAndUpdateDisplay
this.state.requiredRating = null;
const buildingElementSelect = document.getElementById('fr-building-element');
if (!buildingElementSelect) return;
buildingElementSelect.value = this.state.selectedBuildingElement;
const notesInput = document.getElementById('fr-element-notes');
if (notesInput) notesInput.value = this.state.elementNotes;
this._calculateRatingAndUpdateDisplay();
}
}
class InteriorFinishesModule {
constructor(app) {
this.app = app;
this.chapterId = 'interior-finish';
this.state = {
projectOccupancyGroup: this.app.getProjectData().data.projectSummary?.primaryOccupancyGroup || "",
projectSprinkleredStatus: this.app.getProjectData().data.projectSummary?.isSprinklered || false,
selectedLocationInBuilding: "",
requiredFinishIndices: null // Will store { flameSpread: X, smokeDeveloped: Y, notes: "..." }
};
}
_updateResultDisplay() {
const resultDiv = document.getElementById('interior-finish-result');
if (!resultDiv) return;
resultDiv.innerHTML = this._getResultHtml();
}
_calculateFinishRequirements() {
this.state.requiredFinishIndices = null;
const occGroup = this.state.projectOccupancyGroup;
const location = this.state.selectedLocationInBuilding;
if (!occGroup || !location) {
this._updateResultDisplay();
return;
}
if (!G_CODE_DATA?.tables?.interiorFinishRequirements?.data) {
this.app.addOutputMessage(`Ch 8: Interior Finish Requirements data table is missing.`, 'error');
this._updateResultDisplay();
return;
}
const finishDataForOccupancy = G_CODE_DATA.tables.interiorFinishRequirements.data[occGroup];
if (!finishDataForOccupancy) {
this.app.addOutputMessage(`Ch 8: No interior finish data found for Occupancy Group: ${occGroup}.`, 'warning');
this._updateResultDisplay();
return;
}
const finishData = finishDataForOccupancy[location];
if (finishData && finishData.flameSpread !== undefined && finishData.smokeDeveloped !== undefined) {
this.state.requiredFinishIndices = {
flameSpread: finishData.flameSpread,
smokeDeveloped: finishData.smokeDeveloped,
notes: finishData.notes || ""
};
} else {
this.app.addOutputMessage(`Ch 8: Finish requirement data not found for Occ: ${occGroup}, Loc: ${location}.`, 'warning');
}
this._updateResultDisplay();
this.saveData();
}
render() {
const projectSummary = this.app.getProjectData().data.projectSummary;
this.state.projectOccupancyGroup = projectSummary?.primaryOccupancyGroup || this.state.projectOccupancyGroup;
this.state.projectSprinkleredStatus = projectSummary?.isSprinklered === true;
const finishTableData = G_CODE_DATA?.tables?.interiorFinishRequirements?.data;
let occupancyDisplayHtml = `<input type="text" id="if-project-occupancy" value="${this.state.projectOccupancyGroup || 'Not Set (Select in Ch.5)'}" readonly class="form-control-plaintext">`;
if (!this.state.projectOccupancyGroup) {
occupancyDisplayHtml += `<small class="danger-color-text">Set primary occupancy in Chapter 5 first.</small>`;
} else if (finishTableData && !finishTableData[this.state.projectOccupancyGroup]){
occupancyDisplayHtml += `<small class="warning-color-text">Warning: No interior finish data found for occupancy '${this.state.projectOccupancyGroup}' in JSON table.</small>`;
}
let locationOptionsHtml = '<option value="">-- Select Location --</option>';
const locationsForOccupancy = finishTableData?.[this.state.projectOccupancyGroup];
if (locationsForOccupancy) {
Object.keys(locationsForOccupancy).sort().forEach(locKey => {
const displayName = locKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
locationOptionsHtml += `<option value="${locKey}" ${this.state.selectedLocationInBuilding === locKey ? 'selected' : ''}>${displayName}</option>`;
});
} else if (this.state.projectOccupancyGroup) {
locationOptionsHtml = '<option value="">-- No Locations for this Occupancy --</option>';
}
let resultHtml = this._getResultHtml();
const isButtonDisabled = !this.state.projectOccupancyGroup ||
!this.state.selectedLocationInBuilding ||
!locationsForOccupancy ||
!locationsForOccupancy[this.state.selectedLocationInBuilding];
return `
<div class="interior-finishes-module">
<h2>Chapter 8: Interior Finishes</h2>
<div class="form-section">
<h3>Interior Finish Requirements (Table 803.13) <i class="fas fa-info-circle" title="Determine minimum flame spread and smoke developed indices for interior wall and ceiling finishes."></i></h3>
<p>Covers flame spread/smoke development for interior wall/ceiling finishes (IBC Table 803.13 data), combustible materials in Type I/II, decorations.</p>
<div class="form-group">
<label>Project Occupancy Group (from Ch.5/Summary):</label>
${occupancyDisplayHtml}
</div>
<div class="form-group">
<label>Project Sprinkler Status (from Ch.9/Summary):</label>
<input type="text" id="if-sprinkler-status" value="${this.state.projectSprinkleredStatus ? 'Yes (Fully Sprinklered)' : 'No'}" readonly class="form-control-plaintext">
</div>
<div class="form-group">
<label for="if-location">Select Location in Building:</label>
<select id="if-location" ${!this.state.projectOccupancyGroup || !locationsForOccupancy ? 'disabled' : ''}>
${locationOptionsHtml}
</select>
</div>
<button id="calculate-finish-req" ${isButtonDisabled ? 'disabled' : ''}><i class="fas fa-palette"></i> Determine Finish Requirements</button>
</div>
<div class="form-section">
<h3>Required Indices <i class="fas fa-info-circle" title="Calculated flame spread and smoke-developed indices."></i></h3>
<div id="interior-finish-result" style="margin-top: 1.5rem;">${resultHtml}</div>
</div>
${!G_CODE_DATA?.tables?.interiorFinishRequirements?.data ? '<p class="compliance-message error"><i class="fas fa-times-circle"></i> Interior Finish Requirements data table is missing or invalid in G_CODE_DATA. Module disabled.</p>' : ''}
${G_CODE_DATA?.tables?.interiorFinishRequirements?.notes ? `<div class="compliance-message info" style="margin-top:1rem"><i class="fas fa-info-circle"></i><div><h4>General Notes from Table:</h4>${G_CODE_DATA.tables.interiorFinishRequirements.notes.map(n=>`<p>${n}</p>`).join('')}</div></div>` : ''}
</div>
`;
}
_getResultHtml() {
if (!this.state.requiredFinishIndices && !(this.state.projectOccupancyGroup && this.state.selectedLocationInBuilding && G_CODE_DATA)) {
return "";
}
if (this.state.requiredFinishIndices) {
const occDisplay = this.state.projectOccupancyGroup || "N/A";
const locDisplay = this.state.selectedLocationInBuilding.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) || "N/A";
return `
<div class="compliance-message info">
<i class="fas fa-info-circle"></i>
<div>
<h4>Minimum Interior Wall & Ceiling Finish Requirements (IBC Table 803.13 data):</h4>
<p>For Occupancy <strong>${occDisplay}</strong>, Location: <strong>${locDisplay}</strong></p>
<p><strong>Max Flame Spread Index: ${this.state.requiredFinishIndices.flameSpread}</strong></p>
<p><strong>Max Smoke Developed Index: ${this.state.requiredFinishIndices.smokeDeveloped}</strong></p>
${this.state.projectSprinkleredStatus ? "<p><small>Note: Building is sprinklered. Reductions per IBC 803.1.2 may apply (e.g., Class C where B required, or B where A required, but not below C). This calculator shows base values from table; apply reductions manually based on specific code text.</small></p>" : ""}
${this.state.requiredFinishIndices.notes ? `<p><small>Specific Notes: ${this.state.requiredFinishIndices.notes}</small></p>` : ""}
</div>
</div>
`;
} else if (this.state.projectOccupancyGroup && this.state.selectedLocationInBuilding && G_CODE_DATA) {
return `<p class="compliance-message warning"><i class="fas fa-exclamation-triangle"></i> Could not determine finish requirements for the current selection. Click "Determine Finish Requirements" or check code data if already clicked.</p>`;
}
return "";
}
initEvents() {
const projectSummary = this.app.getProjectData().data.projectSummary;
this.state.projectOccupancyGroup = projectSummary?.primaryOccupancyGroup || "";
this.state.projectSprinkleredStatus = projectSummary?.isSprinklered === true;
const occDisplay = document.getElementById('if-project-occupancy');
if(occDisplay) occDisplay.value = this.state.projectOccupancyGroup || 'Not Set (Select in Ch.5)';
const sprDisplay = document.getElementById('if-sprinkler-status');
if(sprDisplay) sprDisplay.value = this.state.projectSprinkleredStatus ? 'Yes (Fully Sprinklered)' : 'No';
const locationSelect = document.getElementById('if-location');
const calculateBtn = document.getElementById('calculate-finish-req');
locationSelect?.addEventListener('change', (e) => {
this.state.selectedLocationInBuilding = e.target.value;
this.state.requiredFinishIndices = null;
this._updateResultDisplay();
const locationsForOccupancy = G_CODE_DATA?.tables?.interiorFinishRequirements?.data?.[this.state.projectOccupancyGroup];
if (calculateBtn) {
calculateBtn.disabled = !this.state.projectOccupancyGroup ||
!this.state.selectedLocationInBuilding ||
!locationsForOccupancy ||
!locationsForOccupancy[this.state.selectedLocationInBuilding];
}
});
if(locationSelect && this.state.selectedLocationInBuilding) {
locationSelect.value = this.state.selectedLocationInBuilding;
}
calculateBtn?.addEventListener('click', () => {
this._calculateFinishRequirements();
});
if (this.state.requiredFinishIndices) {
this._updateResultDisplay();
}
}
saveData() {
// Only save user-selectable state, not derived state.
const data = {
selectedLocationInBuilding: this.state.selectedLocationInBuilding,
};
this.app.updateProjectData(this.chapterId, data);
return data;
}
loadData(data) {
this.state.selectedLocationInBuilding = data.selectedLocationInBuilding || "";
const projectSummary = this.app.getProjectData().data.projectSummary;
this.state.projectOccupancyGroup = projectSummary?.primaryOccupancyGroup || "";
this.state.projectSprinkleredStatus = projectSummary?.isSprinklered === true;
this.state.requiredFinishIndices = null; // Reset for recalculation
const locationSelect = document.getElementById('if-location');
if (!locationSelect) return;
locationSelect.value = this.state.selectedLocationInBuilding;
const locationsForOccupancy = G_CODE_DATA?.tables?.interiorFinishRequirements?.data?.[this.state.projectOccupancyGroup];
const isSelectionValid = this.state.projectOccupancyGroup &&
this.state.selectedLocationInBuilding &&
locationsForOccupancy &&
locationsForOccupancy[this.state.selectedLocationInBuilding];
if (isSelectionValid) {
this._calculateFinishRequirements(); // Recalculate and display
} else {
this._updateResultDisplay(); // Clear display if not valid
}
const calculateBtn = document.getElementById('calculate-finish-req');
if (calculateBtn) {
calculateBtn.disabled = !isSelectionValid;
}
}
}
class FireProtectionModule {
constructor(app) {
this.app = app;
this.chapterId = 'fire-protection';
this.state = {
isSprinklered: false,
sprinklerStandard: 'NFPA13',
hasFireAlarm: false,
fireAlarmType: 'manual'
};
}
render() {
const projectSummary = this.app.getProjectData().data.projectSummary || {};
this.state.isSprinklered = projectSummary.isSprinklered === true;
this.state.hasFireAlarm = projectSummary.hasFireAlarm === true;
this.state.sprinklerStandard = this.state.isSprinklered ? (projectSummary.sprinklerStandard || 'NFPA13') : 'NFPA13';
this.state.fireAlarmType = this.state.hasFireAlarm ? (projectSummary.fireAlarmType || 'manual') : 'manual';
return `
<div class="fire-protection-module">
<h2>Chapter 9: Fire Protection and Life Safety Systems</h2>
<div class="form-section">
<h3>System Definitions <i class="fas fa-info-circle" title="Define the primary fire sprinkler and fire alarm systems for the building."></i></h3>
<p>Define the fire protection and life safety systems for the building.</p>
<div class="form-group">
<label for="is-sprinklered">Is the building fully sprinklered (per applicable NFPA standard)?</label>
<select id="is-sprinklered">
<option value="false" ${!this.state.isSprinklered ? 'selected' : ''}>No</option>
<option value="true" ${this.state.isSprinklered ? 'selected' : ''}>Yes</option>
</select>
</div>
<div class="form-group" id="sprinkler-standard-group" style="display: ${this.state.isSprinklered ? 'block' : 'none'};">
<label for="sprinkler-standard">Sprinkler Standard Installed:</label>
<select id="sprinkler-standard">
<option value="NFPA13" ${this.state.sprinklerStandard === 'NFPA13' ? 'selected' : ''}>NFPA 13 (Full Commercial)</option>
<option value="NFPA13R" ${this.state.sprinklerStandard === 'NFPA13R' ? 'selected' : ''}>NFPA 13R (Residential, up to 4 stories)</option>
<option value="NFPA13D" ${this.state.sprinklerStandard === 'NFPA13D' ? 'selected' : ''}>NFPA 13D (1- & 2-Family Dwellings)</option>
<option value="Other" ${this.state.sprinklerStandard === 'Other' ? 'selected' : ''}>Other (Specify in Notes)</option>
</select>
</div>
<div class="form-group">
<label for="has-fire-alarm">Is a fire alarm system installed/required?</label>
<select id="has-fire-alarm">
<option value="false" ${!this.state.hasFireAlarm ? 'selected' : ''}>No</option>
<option value="true" ${this.state.hasFireAlarm ? 'selected' : ''}>Yes</option>
</select>
</div>
<div class="form-group" id="fire-alarm-type-group" style="display: ${this.state.hasFireAlarm ? 'block' : 'none'};">
<label for="fire-alarm-type">Primary Fire Alarm System Type:</label>
<select id="fire-alarm-type">
<option value="manual" ${this.state.fireAlarmType === 'manual' ? 'selected' : ''}>Manual Pull Stations</option>
<option value="automatic_smoke" ${this.state.fireAlarmType === 'automatic_smoke' ? 'selected' : ''}>Automatic Smoke Detection</option>
<option value="automatic_heat" ${this.state.fireAlarmType === 'automatic_heat' ? 'selected' : ''}>Automatic Heat Detection</option>
<option value="voice_evac" ${this.state.fireAlarmType === 'voice_evac' ? 'selected' : ''}>Emergency Voice/Alarm Communication System</option>
<option value="combination" ${this.state.fireAlarmType === 'combination' ? 'selected' : ''}>Combination (Specify in Notes)</option>
</select>
</div>
<button id="update-fire-protection-data"><i class="fas fa-shield-alt"></i> Update Fire Protection Data</button>
</div>
</div>
`;
}
initEvents() {
const sprinklerSelect = document.getElementById('is-sprinklered');
const standardSelect = document.getElementById('sprinkler-standard');
const alarmSelect = document.getElementById('has-fire-alarm');
const alarmTypeSelect = document.getElementById('fire-alarm-type');
const updateBtn = document.getElementById('update-fire-protection-data');
if(sprinklerSelect) sprinklerSelect.value = this.state.isSprinklered.toString();
if(standardSelect) standardSelect.value = this.state.sprinklerStandard;
if(alarmSelect) alarmSelect.value = this.state.hasFireAlarm.toString();
if(alarmTypeSelect) alarmTypeSelect.value = this.state.fireAlarmType;
this._toggleDependentFields();
sprinklerSelect?.addEventListener('change', (e) => {
this.state.isSprinklered = e.target.value === 'true';
this._toggleDependentFields();
});
standardSelect?.addEventListener('change', (e) => this.state.sprinklerStandard = e.target.value);
alarmSelect?.addEventListener('change', (e) => {
this.state.hasFireAlarm = e.target.value === 'true';
this._toggleDependentFields();
});
alarmTypeSelect?.addEventListener('change', (e) => this.state.fireAlarmType = e.target.value);
updateBtn?.addEventListener('click', () => {
const oldSprinklerStatus = this.app.getProjectData().data.projectSummary?.isSprinklered;
this.saveData();
this.app.addOutputMessage('Ch 9: Fire Protection data updated in Project Summary.', 'success');
if (oldSprinklerStatus !== this.state.isSprinklered) {
this.app.triggerGlobalRecalculationDependency('sprinklerStatusChanged');
}
});
}
_toggleDependentFields() {
const standardGroup = document.getElementById('sprinkler-standard-group');
const alarmTypeGroup = document.getElementById('fire-alarm-type-group');
if(standardGroup) standardGroup.style.display = this.state.isSprinklered ? 'block' : 'none';
if(alarmTypeGroup) alarmTypeGroup.style.display = this.state.hasFireAlarm ? 'block' : 'none';
}
saveData() {
const data = { ...this.state };
this.app.updateProjectData(this.chapterId, data);
this.app.updateProjectData('projectSummary', {
...this.app.getProjectData().data.projectSummary,
isSprinklered: this.state.isSprinklered,
sprinklerStandard: this.state.isSprinklered ? this.state.sprinklerStandard : null,
hasFireAlarm: this.state.hasFireAlarm,
fireAlarmType: this.state.hasFireAlarm ? this.state.fireAlarmType : null,
hasEmergencyVoiceAlarm: this.state.hasFireAlarm && this.state.fireAlarmType === 'voice_evac'
});
return data;
}
loadData(data) {
// Prioritize projectSummary for these critical flags if they exist
const projectSummary = this.app.getProjectData().data.projectSummary || {};
this.state.isSprinklered = projectSummary.isSprinklered !== undefined ? projectSummary.isSprinklered : (data.isSprinklered || false);
this.state.hasFireAlarm = projectSummary.hasFireAlarm !== undefined ? projectSummary.hasFireAlarm : (data.hasFireAlarm || false);
this.state.sprinklerStandard = this.state.isSprinklered
? (projectSummary.sprinklerStandard || data.sprinklerStandard || 'NFPA13')
: (data.sprinklerStandard || 'NFPA13');
this.state.fireAlarmType = this.state.hasFireAlarm
? (projectSummary.fireAlarmType || data.fireAlarmType || 'manual')
: (data.fireAlarmType || 'manual');
const sprinklerSelect = document.getElementById('is-sprinklered');
if (!sprinklerSelect) return;
sprinklerSelect.value = this.state.isSprinklered.toString();
const standardSelect = document.getElementById('sprinkler-standard');
if(standardSelect) standardSelect.value = this.state.sprinklerStandard;
const alarmSelect = document.getElementById('has-fire-alarm');
if(alarmSelect) alarmSelect.value = this.state.hasFireAlarm.toString();
const alarmTypeSelect = document.getElementById('fire-alarm-type');
if(alarmTypeSelect) alarmTypeSelect.value = this.state.fireAlarmType;
this._toggleDependentFields();
}
}
class MeansOfEgressModule {
constructor(app) {
this.app = app;
this.chapterId = 'means-of-egress';
this.state = {
occupantLoadForEgress: 0,
isSprinklered: false,
hasEmergencyVoiceAlarm: false,
occupancyHazardCategory: 'normal_hazard',
primaryOccupancyGroup: '',
requiredEgressWidthStairs: 0,
requiredEgressWidthOther: 0,
minNumberOfExits: 0,
commonPathTravelLimit: 0,
exitAccessTravelDistanceLimit: 0
};
}
render() {
const projectSummary = this.app.getProjectData().data.projectSummary || {};
this.state.isSprinklered = projectSummary.isSprinklered === true;
this.state.hasEmergencyVoiceAlarm = projectSummary.hasEmergencyVoiceAlarm === true;
this.state.primaryOccupancyGroup = projectSummary.primaryOccupancyGroup || '';
const summaryOL = projectSummary.mainFunctionSpaceOccupantLoad;
if (this.state.occupantLoadForEgress === 0 && summaryOL > 0) {
this.state.occupantLoadForEgress = summaryOL;
}
return `
<div class="means-of-egress-module">
<h2>Chapter 10: Means of Egress</h2>
<div class="form-section">
<h3>Egress Parameters <i class="fas fa-info-circle" title="Input occupant load and hazard category. Sprinkler and alarm status are from Project Summary."></i></h3>
<p>Calculate required egress widths, number of exits, and check travel distances (IBC 1005, 1006, 1017).</p>
<div class="form-group">
<label for="occupant-load-for-egress">Occupant Load for this Egress Path/Area:</label>
<input type="number" id="occupant-load-for-egress" min="0" value="${this.state.occupantLoadForEgress}">
<small>From Ch.3 Occupancy or Project Summary (Primary: ${this.state.primaryOccupancyGroup || 'Not Set'}). Enter specific load if needed.</small>
</div>
<div class="form-group">
<label for="occupancy-hazard-category">Occupancy Hazard for Egress Factors:</label>
<select id="occupancy-hazard-category">
<option value="normal_hazard" ${this.state.occupancyHazardCategory === 'normal_hazard' ? 'selected' : ''}>Normal Hazard (Most Occupancies)</option>
<option value="H_hazard" ${this.state.occupancyHazardCategory === 'H_hazard' ? 'selected' : ''}>High Hazard (Groups H)</option>
</select>
<small>Primary Project Occupancy: <strong>${this.state.primaryOccupancyGroup || 'Not Set'}</strong> (used for travel distances/exit counts)</small>
</div>
<p><em>Sprinkler and Voice Alarm status read from Project Summary.</em></p>
<p id="moe-sprinkler-status">Sprinkler Status: <strong>${this.state.isSprinklered ? 'Yes' : 'No'}</strong></p>
<p id="moe-voice-alarm-status">Emergency Voice/Alarm Status: <strong>${this.state.hasEmergencyVoiceAlarm ? 'Yes' : 'No'}</strong></p>
<button id="calculate-egress-requirements" ${!G_CODE_DATA ? 'disabled' : ''}><i class="fas fa-route"></i> Calculate Egress Requirements</button>
</div>
<div class="form-section" id="egress-results-section" style="display: ${this.state.requiredEgressWidthStairs > 0 || this.state.minNumberOfExits > 0 || this.state.commonPathTravelLimit > 0 ? 'block' : 'none'};">
<h3>Results <i class="fas fa-info-circle" title="Calculated egress requirements based on inputs."></i></h3>
<div id="egress-results">
<p>Min. Number of Exits Required: <strong id="min-exits">N/A</strong></p>
<p>Required Egress Width (Stairways): <strong id="req-width-stairs">N/A</strong> inches</p>
<p>Required Egress Width (Other Components): <strong id="req-width-other">N/A</strong> inches</p>
<p>Common Path of Egress Travel Limit: <strong id="common-path-limit">N/A</strong> ft</p>
<p>Exit Access Travel Distance Limit: <strong id="exit-access-limit">N/A</strong> ft</p>
<div id="moe-notes-exceptions" class="compliance-message warning" style="display:none;"><h4>Notes/Exceptions:</h4></div>
</div>
</div>
${!G_CODE_DATA ? '<p class="compliance-message error"><i class="fas fa-times-circle"></i> Code Data not loaded. Calculations disabled.</p>' : ''}
${G_CODE_DATA && (!G_CODE_DATA.tables?.egressWidthFactors || !G_CODE_DATA.tables?.meansOfEgressRequirements) ? '<p class="compliance-message warning"><i class="fas fa-exclamation-triangle"></i> One or more required data tables (egressWidthFactors, meansOfEgressRequirements) missing from JSON.</p>' : ''}
</div>
`;
}
initEvents() {
const olInput = document.getElementById('occupant-load-for-egress');
const hazardSelect = document.getElementById('occupancy-hazard-category');
const calculateBtn = document.getElementById('calculate-egress-requirements');
const projectSummary = this.app.getProjectData().data.projectSummary || {};
this.state.isSprinklered = projectSummary.isSprinklered === true;
this.state.hasEmergencyVoiceAlarm = projectSummary.hasEmergencyVoiceAlarm === true;
this.state.primaryOccupancyGroup = projectSummary.primaryOccupancyGroup || '';
const summaryOL = projectSummary.mainFunctionSpaceOccupantLoad;
if (this.state.occupantLoadForEgress === 0 && summaryOL > 0) {
this.state.occupantLoadForEgress = summaryOL;
}
if (olInput) olInput.value = this.state.occupantLoadForEgress;
if (hazardSelect) hazardSelect.value = this.state.occupancyHazardCategory;
olInput?.addEventListener('input', e => {
this.state.occupantLoadForEgress = parseInt(e.target.value) || 0;
this._clearAndHideResults();
});
hazardSelect?.addEventListener('change', e => {
this.state.occupancyHazardCategory = e.target.value;
this._clearAndHideResults();
});
calculateBtn?.addEventListener('click', () => this.calculate());
this.updateSystemStatusDisplay();
if (this.state.requiredEgressWidthStairs > 0 || this.state.minNumberOfExits > 0 || this.state.commonPathTravelLimit > 0 || this.state.exitAccessTravelDistanceLimit > 0) {
this._updateResultDisplayElements();
}
}
_updateResultDisplayElements() {
const resultsSectionDiv = document.getElementById('egress-results-section');
if (!resultsSectionDiv) return;
const minExitsEl = document.getElementById('min-exits');
const reqWidthStairsEl = document.getElementById('req-width-stairs');
const reqWidthOtherEl = document.getElementById('req-width-other');
const commonPathLimitEl = document.getElementById('common-path-limit');
const exitAccessLimitEl = document.getElementById('exit-access-limit');
const hasResults = (typeof this.state.minNumberOfExits === 'number' && this.state.minNumberOfExits >= 0) || // Allow 0 exits for OL=0
(typeof this.state.requiredEgressWidthStairs === 'number' && this.state.requiredEgressWidthStairs >= 0) ||
(typeof this.state.commonPathTravelLimit === 'number' && this.state.commonPathTravelLimit >= 0) ||
(typeof this.state.exitAccessTravelDistanceLimit === 'number' && this.state.exitAccessTravelDistanceLimit >= 0) ||
this.state.minNumberOfExits === 'N/A';
if (hasResults) {
if(minExitsEl) minExitsEl.textContent = this.state.minNumberOfExits;
if(reqWidthStairsEl) reqWidthStairsEl.textContent = typeof this.state.requiredEgressWidthStairs === 'number' ? this.state.requiredEgressWidthStairs.toFixed(1) : 'N/A';
if(reqWidthOtherEl) reqWidthOtherEl.textContent = typeof this.state.requiredEgressWidthOther === 'number' ? this.state.requiredEgressWidthOther.toFixed(1) : 'N/A';
if(commonPathLimitEl) commonPathLimitEl.textContent = this.state.commonPathTravelLimit;
if(exitAccessLimitEl) exitAccessLimitEl.textContent = this.state.exitAccessTravelDistanceLimit;
resultsSectionDiv.style.display = 'block';
} else {
if(minExitsEl) minExitsEl.textContent = 'N/A';
if(reqWidthStairsEl) reqWidthStairsEl.textContent = 'N/A';
if(reqWidthOtherEl) reqWidthOtherEl.textContent = 'N/A';
if(commonPathLimitEl) commonPathLimitEl.textContent = 'N/A';
if(exitAccessLimitEl) exitAccessLimitEl.textContent = 'N/A';
resultsSectionDiv.style.display = 'none';
}
}
updateSystemStatusDisplay() {
const projectSummary = this.app.getProjectData().data.projectSummary || {};
this.state.isSprinklered = projectSummary.isSprinklered === true;
this.state.hasEmergencyVoiceAlarm = projectSummary.hasEmergencyVoiceAlarm === true;
this.state.primaryOccupancyGroup = projectSummary.primaryOccupancyGroup || '';
const sprinklerStatusEl = document.getElementById('moe-sprinkler-status');
const voiceAlarmEl = document.getElementById('moe-voice-alarm-status');
if(sprinklerStatusEl) sprinklerStatusEl.innerHTML = `Sprinkler Status: <strong>${this.state.isSprinklered ? 'Yes' : 'No'}</strong>`;
if(voiceAlarmEl) voiceAlarmEl.innerHTML = `Emergency Voice/Alarm Status: <strong>${this.state.hasEmergencyVoiceAlarm ? 'Yes' : 'No'}</strong>`;
const hazardSelect = document.getElementById('occupancy-hazard-category');
if (hazardSelect?.nextElementSibling?.tagName === 'SMALL') {
hazardSelect.nextElementSibling.innerHTML = `Primary Project Occupancy: <strong>${this.state.primaryOccupancyGroup || 'Not Set'}</strong> (used for travel distances/exit counts)`;
}
const olInput = document.getElementById('occupant-load-for-egress');
if (olInput?.nextElementSibling?.tagName === 'SMALL') {
olInput.nextElementSibling.innerHTML = `From Ch.3 Occupancy or Project Summary (Primary: ${this.state.primaryOccupancyGroup || 'Not Set'}). Enter specific load if needed.`;
}
}
calculate() {
this.updateSystemStatusDisplay();
if (this.state.occupantLoadForEgress < 0) { // Allow OL = 0
this.app.addOutputMessage('Ch 10: Occupant Load cannot be negative.', 'error');
this._clearAndHideResults();
return;
}
if (!this.state.primaryOccupancyGroup && this.state.occupantLoadForEgress > 0) { // Warning if OL > 0 but no primary occ
this.app.addOutputMessage('Ch 10: Primary Occupancy Group not set in Project Summary (Ch 5). Needed for exit counts and travel distances.', 'warning');
}
let egressFactorKeyBase = this.state.isSprinklered ? 'sprinklered' : 'non_sprinklered';
let egressFactorKey = `${egressFactorKeyBase}_no_alarm`;
if (this.state.isSprinklered && this.state.hasEmergencyVoiceAlarm) {
egressFactorKey = `sprinklered_with_alarm`;
}
const egressFactorsTable = G_CODE_DATA?.tables?.egressWidthFactors?.data;
let factorsToUse = egressFactorsTable?.[egressFactorKey];
let widthNotes = Array.isArray(G_CODE_DATA?.tables?.egressWidthFactors?.notes) ? [...G_CODE_DATA.tables.egressWidthFactors.notes] : [];
if (!factorsToUse) {
factorsToUse = egressFactorsTable?.[`${egressFactorKeyBase}_no_alarm`];
if (factorsToUse && this.state.isSprinklered && this.state.hasEmergencyVoiceAlarm) {
this.app.addOutputMessage(`Ch 10: Egress width factors for 'sprinklered_with_alarm' not found. Using 'sprinklered_no_alarm' factors. Benefits of voice alarm may not be reflected.`, 'warning');
widthNotes.push("Used 'sprinklered_no_alarm' factors due to missing 'sprinklered_with_alarm' data.");
} else if (!factorsToUse) {
this.app.addOutputMessage(`Ch 10: Egress width factors not found for key '${egressFactorKey}' or base '${egressFactorKeyBase}_no_alarm'. Check data. Using default 0.3/0.2.`, 'warning');
factorsToUse = { stairways_in_per_occ: 0.3, other_egress_comp_in_per_occ: 0.2 };
widthNotes.push("Used fallback egress width factors (0.3 stairs / 0.2 other).");
}
}
if (this.state.occupancyHazardCategory === 'H_hazard') {
this.app.addOutputMessage(`Ch 10: High Hazard (H) selected. Verify factors against IBC 1005.3.1/1005.3.2 for H occupancies. Using standard factors.`, 'warning');
widthNotes.push("High Hazard (H) selected. Verify factors per IBC 1005.3.1/2.");
}
this.state.requiredEgressWidthStairs = this.state.occupantLoadForEgress * parseFloat(factorsToUse.stairways_in_per_occ || 0.3);
this.state.requiredEgressWidthOther = this.state.occupantLoadForEgress * parseFloat(factorsToUse.other_egress_comp_in_per_occ || 0.2);
if (factorsToUse.notes && !widthNotes.includes(factorsToUse.notes)) widthNotes.push(factorsToUse.notes);
let notesAndExceptions = [...widthNotes];
const ol = this.state.occupantLoadForEgress;
const primaryOcc = this.state.primaryOccupancyGroup;
if (primaryOcc || ol === 0) { // Allow calculation for OL=0 as well, as some occupancies might still have min exit=1
this.state.minNumberOfExits = this._calculateMinExits(primaryOcc, ol, notesAndExceptions);
const travelDistData = this._getTravelDistances(primaryOcc, this.state.isSprinklered);
this.state.commonPathTravelLimit = travelDistData.commonPath;
this.state.exitAccessTravelDistanceLimit = travelDistData.exitAccess;
if (travelDistData.note && !notesAndExceptions.includes(travelDistData.note)) notesAndExceptions.push(travelDistData.note);
} else {
this.state.minNumberOfExits = 'N/A';
this.state.commonPathTravelLimit = 'N/A';
this.state.exitAccessTravelDistanceLimit = 'N/A';
notesAndExceptions.push("Primary Occupancy Group needed for Exit Count and Travel Distance calculations.");
}
this._displayFullResults(notesAndExceptions);
this.saveData(); // Save the calculated state
// Update projectSummary for the Summary tab
const projectSummaryUpdates = {
egress_occupantLoad: this.state.occupantLoadForEgress,
egress_requiredWidthStairs: this.state.requiredEgressWidthStairs,
egress_requiredWidthOther: this.state.requiredEgressWidthOther,
egress_minNumberOfExits: this.state.minNumberOfExits,
egress_commonPathTravelLimit: this.state.commonPathTravelLimit,
egress_exitAccessTravelDistanceLimit: this.state.exitAccessTravelDistanceLimit
};
this.app.updateProjectData('projectSummary', {
...this.app.getProjectData().data.projectSummary,
...projectSummaryUpdates
});
this.app.addOutputMessage(
`Ch 10: OL=${ol}, Occ=${primaryOcc || 'N/A'}, Sprk=${this.state.isSprinklered}. Exits>=${this.state.minNumberOfExits}, Width(St/Oth): ${this.state.requiredEgressWidthStairs.toFixed(1)}/${this.state.requiredEgressWidthOther.toFixed(1)}in.`,
'success'
);
}
_calculateMinExits(occupancyGroup, occupantLoad, notesRef) {
if (occupantLoad === 0) {
if (notesRef && typeof notesRef.push === 'function' && !notesRef.some(n => n.includes("OL=0"))) notesRef.push("Occupant Load is 0. Min exits per general rules or specific occupancy requirements (e.g., I-2).");
// For OL=0, generally 0 exits needed unless specific occupancy requires otherwise (e.g. I-2 might still need 2)
// A more nuanced check for I-2 could be added here if needed.
return (occupancyGroup === 'I-2' || occupancyGroup === 'I-2.1') ? 2 : 0;
}
const moeReqTable = G_CODE_DATA?.tables?.meansOfEgressRequirements?.data;
if (moeReqTable && moeReqTable[occupancyGroup]?.number_of_exits) {
const exitRanges = moeReqTable[occupancyGroup].number_of_exits;
if (Array.isArray(exitRanges)) {
for (const range of exitRanges) {
const [minOLStr, maxOLStr] = range.occupant_load.split('-');
const minOL = parseInt(minOLStr);
const maxOL = maxOLStr === '+' ? Infinity : parseInt(maxOLStr);
if (occupantLoad >= minOL && occupantLoad <= maxOL) {
if(notesRef && typeof notesRef.push === 'function' && !notesRef.some(n => n.includes("Min. exits from 'meansOfEgressRequirements'"))) notesRef.push(`Min. exits from 'meansOfEgressRequirements' table for ${occupancyGroup}.`);
return range.required_exits;
}
}
if(notesRef && typeof notesRef.push === 'function' && !notesRef.some(n => n.includes("OL outside defined ranges"))) notesRef.push(`OL outside defined ranges in 'meansOfEgressRequirements' for ${occupancyGroup}. Using general rules.`);
}
} else {
if(notesRef && typeof notesRef.push === 'function' && !notesRef.some(n => n.includes("No specific exit count data"))) notesRef.push(`No specific exit count data in 'meansOfEgressRequirements' for ${occupancyGroup}. Using general rules.`);
}
if(notesRef && typeof notesRef.push === 'function' && !notesRef.some(n => n.includes("Min. exits based on simplified general IBC rules"))) notesRef.push("Min. exits based on simplified general IBC rules.");
let exits = 1;
if (occupancyGroup.startsWith('H-1') || occupancyGroup.startsWith('H-2') || occupancyGroup.startsWith('H-3')) {
exits = (occupantLoad >= 1) ? 2 : 1; // H-1,2,3 need 2 if any occupants
} else if (occupancyGroup.startsWith('H')) { // Other H
exits = (occupantLoad > 10) ? 2 : 1;
} else if (occupancyGroup === 'I-2' || occupancyGroup === 'I-2.1') { // I-2 always 2
exits = 2;
} else if (['I-1', 'I-3', 'I-4'].includes(occupancyGroup)) {
exits = (occupantLoad > 10) ? 2 : 1;
} else if (occupancyGroup.startsWith('R')) {
exits = (occupantLoad > 10) ? 2 : 1;
} else { // General Occupancies (A,B,E,F,M,S,U)
if (occupantLoad >= 1 && occupantLoad <= 50) exits = 1;
else if (occupantLoad >= 51 && occupantLoad <= 500) exits = 2;
else if (occupantLoad >= 501 && occupantLoad <= 1000) exits = 3;
else if (occupantLoad > 1000) exits = 4;
}
// Double check: most occupancies need 2 exits if OL > 50, except some specific cases
if (occupantLoad > 50 && exits < 2 && ['A', 'B', 'E', 'F', 'M', 'S', 'U'].includes(occupancyGroup.split('-')[0])) {
exits = 2;
if(notesRef && typeof notesRef.push === 'function' && !notesRef.some(n => n.includes("OL > 50"))) notesRef.push("OL > 50; min 2 exits generally required (IBC 1006.2.1).");
}
if (occupantLoad > 0 && exits < 1) exits = 1; // Ensure at least 1 exit if there are occupants
return exits;
}
_getTravelDistances(occupancyGroup, isSprinklered) {
const moeReqTable = G_CODE_DATA?.tables?.meansOfEgressRequirements?.data;
let commonPath = isSprinklered ? 75 : 50;
let exitAccess = isSprinklered ? 250 : 200;
let note = "";
if (moeReqTable && moeReqTable[occupancyGroup]) {
const data = moeReqTable[occupancyGroup];
if (data.common_path_of_travel && data.common_path_of_travel.sprinklered !== undefined) {
commonPath = isSprinklered ? data.common_path_of_travel.sprinklered : data.common_path_of_travel.non_sprinklered;
} else {
note += ` Common path default used; `;
}
if (data.exit_access_travel_distance && data.exit_access_travel_distance.sprinklered !== undefined) {
exitAccess = isSprinklered ? data.exit_access_travel_distance.sprinklered : data.exit_access_travel_distance.non_sprinklered;
} else {
note += ` Exit access default used; `;
}
note = `Travel distances from 'meansOfEgressRequirements' table for ${occupancyGroup}. ` + note;
} else {
note = `Travel distance data missing for ${occupancyGroup}. Using general defaults (Verify with IBC Table 1017.2).`;
}
commonPath = typeof commonPath === 'number' ? commonPath : 'N/A';
exitAccess = typeof exitAccess === 'number' ? exitAccess : 'N/A';
return { commonPath, exitAccess, note: note.trim() };
}
_clearAndHideResults() {
this.state.requiredEgressWidthStairs= 0; this.state.requiredEgressWidthOther= 0; this.state.minNumberOfExits= 0;
this.state.commonPathTravelLimit= 0; this.state.exitAccessTravelDistanceLimit = 0;
this._updateResultDisplayElements();
const notesEl = document.getElementById('moe-notes-exceptions');
if(notesEl) {
notesEl.style.display = 'none';
notesEl.innerHTML = '<h4>Notes/Exceptions:</h4>';
}
}
_displayFullResults(notesAndExceptionsArray) {
this._updateResultDisplayElements();
const notesEl = document.getElementById('moe-notes-exceptions');
if (!notesEl) return;
notesEl.innerHTML = '<h4>Notes/Exceptions:</h4>';
let hasContent = false;
if(notesAndExceptionsArray && notesAndExceptionsArray.length > 0){
[...new Set(notesAndExceptionsArray)].forEach(note => {
if(note) {
const p = document.createElement('p');
const safeNote = note.toString().replace(/</g, "&lt;").replace(/>/g, "&gt;");
p.innerHTML = safeNote;
notesEl.appendChild(p);
hasContent = true;
}
});
}
notesEl.style.display = hasContent ? 'block' : 'none';
}
saveData() {
// Save only user-input or calculated state for persistence
const data = {
occupantLoadForEgress: this.state.occupantLoadForEgress,
occupancyHazardCategory: this.state.occupancyHazardCategory,
// Store calculated values for display on reload, they will be overwritten if recalculate is hit
requiredEgressWidthStairs: this.state.requiredEgressWidthStairs,
requiredEgressWidthOther: this.state.requiredEgressWidthOther,
minNumberOfExits: this.state.minNumberOfExits,
commonPathTravelLimit: this.state.commonPathTravelLimit,
exitAccessTravelDistanceLimit: this.state.exitAccessTravelDistanceLimit
};
this.app.updateProjectData(this.chapterId, data);
return data;
}
loadData(data) {
// Load persisted data or defaults
this.state.occupantLoadForEgress = data.occupantLoadForEgress || 0;
this.state.occupancyHazardCategory = data.occupancyHazardCategory || 'normal_hazard';
this.state.requiredEgressWidthStairs = data.requiredEgressWidthStairs || 0;
this.state.requiredEgressWidthOther = data.requiredEgressWidthOther || 0;
this.state.minNumberOfExits = data.minNumberOfExits || 0;
this.state.commonPathTravelLimit = data.commonPathTravelLimit || 0;
this.state.exitAccessTravelDistanceLimit = data.exitAccessTravelDistanceLimit || 0;
// Refresh system status from project summary
const projectSummary = this.app.getProjectData().data.projectSummary || {};
this.state.isSprinklered = projectSummary.isSprinklered === true;
this.state.hasEmergencyVoiceAlarm = projectSummary.hasEmergencyVoiceAlarm === true;
this.state.primaryOccupancyGroup = projectSummary.primaryOccupancyGroup || '';
// If OL was 0 but project summary has a main OL, use that
const summaryOL = projectSummary.mainFunctionSpaceOccupantLoad;
if (this.state.occupantLoadForEgress === 0 && summaryOL > 0) {
this.state.occupantLoadForEgress = summaryOL;
}
const olInput = document.getElementById('occupant-load-for-egress');
if (!olInput) return;
olInput.value = this.state.occupantLoadForEgress;
const hazardSelect = document.getElementById('occupancy-hazard-category');
if (hazardSelect) hazardSelect.value = this.state.occupancyHazardCategory;
this.updateSystemStatusDisplay();
// Display loaded/calculated results or clear if not applicable
if (this.state.requiredEgressWidthStairs > 0 || this.state.minNumberOfExits > 0 ||
this.state.commonPathTravelLimit > 0 || this.state.exitAccessTravelDistanceLimit > 0 ||
this.state.minNumberOfExits === 'N/A' || // Handle 'N/A' case if loaded
(this.state.occupantLoadForEgress === 0 && this.state.minNumberOfExits === 0)) { // Show if OL is 0 and min exits is 0
this._updateResultDisplayElements();
// Consider if notes need to be re-shown or re-calculated
// For simplicity on load, we might not persist the exact notes array for display,
// as they are context-dependent on the calculate() run.
// If results are present, show the section
const resultsSectionDiv = document.getElementById('egress-results-section');
if (resultsSectionDiv) resultsSectionDiv.style.display = 'block';
const notesEl = document.getElementById('moe-notes-exceptions');
if(notesEl) { // Clear old notes if any
notesEl.innerHTML = '<h4>Notes/Exceptions:</h4><p><small>Notes are generated upon calculation.</small></p>';
notesEl.style.display = 'block'; // Keep section visible but indicate notes require recalc
}
} else {
this._clearAndHideResults();
}
}
}
class AccessibilityModule {
constructor(app) {
this.app = app;
this.chapterId = 'accessibility';
this.state = {
accessibleRouteProvided: false,
accessibleEntrances: 0,
accessibleParkingSpaces: 0,
accessibleRestrooms: false,
selectedElementForDetails: "",
elementDetails: null, // Will store { property: value, ... }
notes: ""
};
}
render() {
let elementOptionsHtml = '<option value="">-- Select Element --</option>';
const accElementsTable = G_CODE_DATA?.tables?.accessibleRouteRequirements?.data;
if (accElementsTable) {
Object.keys(accElementsTable).sort().forEach(key => {
const displayName = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
elementOptionsHtml += `<option value="${key}" ${this.state.selectedElementForDetails === key ? 'selected' : ''}>${displayName}</option>`;
});
} else if (G_CODE_DATA) {
elementOptionsHtml = '<option value="">-- Accessibility Table Missing --</option>';
}
let detailsHtml = this._getElementDetailsHtml();
return `
<div class="accessibility-module">
<h2>Chapter 11: Accessibility</h2>
<p>Provides requirements for accessibility, referencing ICC A117.1. This is a very basic overview.</p>
<div class="form-section">
<h3>General Accessibility Provisions <i class="fas fa-info-circle" title="Track key accessibility features of the project."></i></h3>
<div class="form-group">
<label for="acc-route">Accessible route provided to all accessible spaces/elements?</label>
<select id="acc-route">
<option value="false" ${!this.state.accessibleRouteProvided ? 'selected' : ''}>No</option>
<option value="true" ${this.state.accessibleRouteProvided ? 'selected' : ''}>Yes</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label for="acc-entrances">Number of Accessible Public Entrances (min):</label>
<input type="number" id="acc-entrances" min="0" value="${this.state.accessibleEntrances}">
<small>IBC 1105: >=60% of public entrances, min 1.</small>
</div>
<div class="form-group">
<label for="acc-parking">Number of Accessible Parking Spaces Provided:</label>
<input type="number" id="acc-parking" min="0" value="${this.state.accessibleParkingSpaces}">
<small>Refer to IBC Table 1106.1.</small>
</div>
</div>
<div class="form-group">
<label for="acc-restrooms">Accessible restrooms provided as required?</label>
<select id="acc-restrooms">
<option value="false" ${!this.state.accessibleRestrooms ? 'selected' : ''}>No</option>
<option value="true" ${this.state.accessibleRestrooms ? 'selected' : ''}>Yes</option>
</select>
</div>
</div>
<div class="form-section">
<h3>Element Details Review <i class="fas fa-info-circle" title="Review specific dimensional requirements from the 'accessibleRouteRequirements' table in loaded code data."></i></h3>
<div class="form-group">
<label for="acc-element-select">Select Element:</label>
<select id="acc-element-select" ${!accElementsTable ? 'disabled' : ''}>${elementOptionsHtml}</select>
</div>
<div id="acc-element-details-display">${detailsHtml}</div>
</div>
<div class="form-section">
<h3>Notes <i class="fas fa-info-circle" title="General notes regarding accessibility compliance."></i></h3>
<div class="form-group">
<label for="acc-notes">Accessibility Notes:</label>
<textarea id="acc-notes" rows="3" placeholder="Specific challenges, features, ICC A117.1 sections...">${this.state.notes}</textarea>
</div>
<button id="save-accessibility-data"><i class="fas fa-universal-access"></i> Save Accessibility Data</button>
</div>
${!G_CODE_DATA ? '<p class="compliance-message error"><i class="fas fa-times-circle"></i> Code Data not loaded.</p>' : ''}
${G_CODE_DATA && !accElementsTable ? '<p class="compliance-message warning"><i class="fas fa-exclamation-triangle"></i> Accessible Route Requirements data table not found in G_CODE_DATA.</p>' : ''}
</div>
`;
}
_getElementDetailsHtml() {
if (!this.state.selectedElementForDetails || !this.state.elementDetails) return "";
const elementName = this.state.selectedElementForDetails.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
let detailsHtml = `<div class="compliance-message info"><i class="fas fa-info-circle"></i><div><h4>Details for ${elementName}:</h4><ul>`;
for (const prop in this.state.elementDetails) {
let value = this.state.elementDetails[prop];
const propName = prop.replace(/_/g,' ').replace(/\b\w/g,l=>l.toUpperCase());
if (typeof value === 'object' && value !== null) {
value = Object.entries(value).map(([k,v]) => `${k.replace(/_/g,' ')}: ${v}`).join(', ');
} else if (value === null || value === undefined) {
value = 'N/A';
}
detailsHtml += `<li><strong>${propName}:</strong> ${value}</li>`;
}
const generalNotes = G_CODE_DATA?.tables?.accessibleRouteRequirements?.notes;
detailsHtml += `</ul>${generalNotes ? generalNotes.map(n => `<small>${n}</small><br>`).join('') : ''}</div></div>`;
return detailsHtml;
}
initEvents() {
const accRouteEl = document.getElementById('acc-route');
const accEntrancesEl = document.getElementById('acc-entrances');
const accParkingEl = document.getElementById('acc-parking');
const accRestroomsEl = document.getElementById('acc-restrooms');
const accNotesEl = document.getElementById('acc-notes');
const elementSelect = document.getElementById('acc-element-select');
const saveBtn = document.getElementById('save-accessibility-data');
if (accRouteEl) accRouteEl.value = this.state.accessibleRouteProvided.toString();
if (accEntrancesEl) accEntrancesEl.value = this.state.accessibleEntrances;
if (accParkingEl) accParkingEl.value = this.state.accessibleParkingSpaces;
if (accRestroomsEl) accRestroomsEl.value = this.state.accessibleRestrooms.toString();
if (accNotesEl) accNotesEl.value = this.state.notes;
if (elementSelect) elementSelect.value = this.state.selectedElementForDetails;
accRouteEl?.addEventListener('change', (e) => this.state.accessibleRouteProvided = e.target.value === 'true');
accEntrancesEl?.addEventListener('input', (e) => this.state.accessibleEntrances = parseInt(e.target.value) || 0);
accParkingEl?.addEventListener('input', (e) => this.state.accessibleParkingSpaces = parseInt(e.target.value) || 0);
accRestroomsEl?.addEventListener('change', (e) => this.state.accessibleRestrooms = e.target.value === 'true');
accNotesEl?.addEventListener('input', (e) => this.state.notes = e.target.value);
elementSelect?.addEventListener('change', (e) => {
this.state.selectedElementForDetails = e.target.value;
this._fetchAndUpdateElementDetails();
});
saveBtn?.addEventListener('click', () => {
this.saveData();
this.app.addOutputMessage('Ch 11: Accessibility data saved.', 'success');
});
if(this.state.selectedElementForDetails) this._fetchAndUpdateElementDetails(); // Load details if a selection was persisted
}
_fetchAndUpdateElementDetails(){
this.state.elementDetails = null;
if(this.state.selectedElementForDetails && G_CODE_DATA?.tables?.accessibleRouteRequirements?.data){
this.state.elementDetails = G_CODE_DATA.tables.accessibleRouteRequirements.data[this.state.selectedElementForDetails] || null;
}
const detailsDisplay = document.getElementById('acc-element-details-display');
if(detailsDisplay){
detailsDisplay.innerHTML = this._getElementDetailsHtml();
}
}
saveData() {
// Save only user-selectable/input state
const data = {
accessibleRouteProvided: this.state.accessibleRouteProvided,
accessibleEntrances: this.state.accessibleEntrances,
accessibleParkingSpaces: this.state.accessibleParkingSpaces,
accessibleRestrooms: this.state.accessibleRestrooms,
selectedElementForDetails: this.state.selectedElementForDetails, // Save selection
notes: this.state.notes
};
this.app.updateProjectData(this.chapterId, data);
return data;
}
loadData(data) {
this.state = { ...this.state, ...data };
this.state.elementDetails = null; // elementDetails will be re-fetched
const accRouteEl = document.getElementById('acc-route');
if (accRouteEl) {
accRouteEl.value = this.state.accessibleRouteProvided.toString();
const accEntrancesEl = document.getElementById('acc-entrances');
if(accEntrancesEl) accEntrancesEl.value = this.state.accessibleEntrances;
const accParkingEl = document.getElementById('acc-parking');
if(accParkingEl) accParkingEl.value = this.state.accessibleParkingSpaces;
const accRestroomsEl = document.getElementById('acc-restrooms');
if(accRestroomsEl) accRestroomsEl.value = this.state.accessibleRestrooms.toString();
const accNotesEl = document.getElementById('acc-notes');
if(accNotesEl) accNotesEl.value = this.state.notes;
const elementSelect = document.getElementById('acc-element-select');
if(elementSelect) elementSelect.value = this.state.selectedElementForDetails;
this._fetchAndUpdateElementDetails(); // Re-fetch and display details for the loaded selection
}
}
}
class InteriorEnvironmentModule {
constructor(app) {
this.app = app;
this.chapterId = 'interior-environment';
this.state = {
projectOccupancyGroup: this.app.getProjectData().data.projectSummary?.primaryOccupancyGroup || "",
occupantLoadForFixtures: this.app.getProjectData().data.projectSummary?.mainFunctionSpaceOccupantLoad || 0,
selectedFixtureType: "",
calculatedFixtures: null, // Will store { count: X, notes: "...", method: "..." }
manualIENotes: ""
};
}
_getFixtureResultHtml() {
if (!this.state.calculatedFixtures) return "";
const occDisplay = this.state.projectOccupancyGroup || "N/A";
const fixDisplay = this.state.selectedFixtureType.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()) || "N/A";
return `
<div class="compliance-message info">
<i class="fas fa-info-circle"></i>
<div>
<h4>Minimum Required Fixtures (Inspired by IBC Table 2902.1):</h4>
<p>For Occupancy <strong>${occDisplay}</strong>, OL: <strong>${this.state.occupantLoadForFixtures}</strong>, Fixture: <strong>${fixDisplay}</strong></p>
<p><strong>Calculated Minimum: ${this.state.calculatedFixtures.count}</strong></p>
${this.state.calculatedFixtures.notes ? `<p><small>Notes: ${this.state.calculatedFixtures.notes}</small></p>` : ''}
</div>
</div>
`;
}
render() {
const projectSummary = this.app.getProjectData().data.projectSummary;
this.state.projectOccupancyGroup = projectSummary?.primaryOccupancyGroup || this.state.projectOccupancyGroup;
const ch3OL = this.app.getProjectData().data?.occupancy?.calculatedOccupantLoad;
this.state.occupantLoadForFixtures = ch3OL > 0 ? ch3OL : (projectSummary?.mainFunctionSpaceOccupantLoad || this.state.occupantLoadForFixtures || 0);
const fixtureTable = G_CODE_DATA?.tables?.plumbingFixtureCounts?.data;
const occupancyClassData = G_CODE_DATA?.tables?.occupancyClassifications?.data || {};
let occupancySelectorHtml = `<input type="text" id="ie-project-occupancy" value="${this.state.projectOccupancyGroup || 'Not Set (Select in Ch.5)'}" readonly class="form-control-plaintext">`;
let occupancyWarning = '';
if (!this.state.projectOccupancyGroup) {
let occOptions = '<option value="">-- Select Occupancy for Fixtures --</option>';
if (fixtureTable) {
Object.keys(fixtureTable).sort().forEach(og => {
const desc = occupancyClassData[og]?.description || og;
occOptions += `<option value="${og}">${og} - ${desc}</option>`;
});
} else if (G_CODE_DATA) {
occOptions = '<option value="">-- Fixture Table Missing --</option>';
}
occupancySelectorHtml = `<select id="ie-temp-occupancy-group">${occOptions}</select><small>Primary occupancy not set in Ch.5. Select here temporarily.</small>`;
} else if (fixtureTable && !fixtureTable[this.state.projectOccupancyGroup]){
occupancyWarning = `<small class="warning-color-text">Warning: No plumbing fixture data found for primary occupancy '${this.state.projectOccupancyGroup}' in JSON table.</small>`;
}
let fixtureOptionsHtml = '<option value="">-- Select Fixture Type --</option>';
const fixturesForOccupancy = fixtureTable?.[this.state.projectOccupancyGroup];
if (fixturesForOccupancy) {
Object.keys(fixturesForOccupancy).sort().forEach(fixKey => {
const displayName = fixKey.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
fixtureOptionsHtml += `<option value="${fixKey}" ${this.state.selectedFixtureType === fixKey ? 'selected' : ''}>${displayName}</option>`;
});
} else if (this.state.projectOccupancyGroup) {
fixtureOptionsHtml = '<option value="">-- No Fixtures Listed for this Occupancy --</option>';
}
let resultHtml = this._getFixtureResultHtml();
const isCalcButtonDisabled = !this.state.projectOccupancyGroup ||
!this.state.selectedFixtureType ||
!fixturesForOccupancy ||
!fixturesForOccupancy[this.state.selectedFixtureType];
return `
<div class="interior-environment-module">
<h2>Chapter 12: Interior Environment & (Ch 29 Cross-Ref: Plumbing Fixtures)</h2>
<p>Covers light, ventilation, sanitation, temp control, noise. Sanitation includes plumbing fixture counts (IBC Ch.29).</p>
<div class="form-section">
<h3>Plumbing Fixture Calculation (Simplified) <i class="fas fa-info-circle" title="Calculate minimum required plumbing fixtures based on occupancy and occupant load (inspired by IBC Table 2902.1)."></i></h3>
<div class="form-group">
<label>Project Occupancy Group:</label>
${occupancySelectorHtml}
${occupancyWarning}
</div>
<div class="form-group">
<label for="ie-occupant-load">Occupant Load for Fixture Calculation:</label>
<input type="number" id="ie-occupant-load" min="0" value="${this.state.occupantLoadForFixtures}">
<small>From Ch.3 Occupancy calculation or Project Summary for main space.</small>
</div>
<div class="form-group">
<label for="ie-fixture-type">Select Fixture Type:</label>
<select id="ie-fixture-type" ${!this.state.projectOccupancyGroup || !fixturesForOccupancy ? 'disabled' : ''}>
${fixtureOptionsHtml}
</select>
</div>
<button id="calculate-fixtures" ${isCalcButtonDisabled ? 'disabled' : ''}><i class="fas fa-toilet"></i> Calculate Min. Fixtures</button>
</div>
<div class="form-section">
<h3>Fixture Calculation Result <i class="fas fa-info-circle" title="Displays the calculated minimum number of fixtures."></i></h3>
<div id="fixture-calculation-result" style="margin-top: 1.5rem;">${resultHtml}</div>
</div>
<div class="form-section">
<h3>General Interior Environment Notes <i class="fas fa-info-circle" title="Notes for light, ventilation, temperature control, noise, etc."></i></h3>
<div class="form-group">
<label for="ie-manual-notes">General Interior Environment Notes (Light, Vent, etc.):</label>
<textarea id="ie-manual-notes" rows="3" placeholder="e.g., Natural light provisions, ventilation strategy...">${this.state.manualIENotes}</textarea>
</div>
<button id="save-ie-notes"><i class="fas fa-save"></i> Save Notes</button>
</div>
${!G_CODE_DATA?.tables?.plumbingFixtureCounts ? '<p class="compliance-message error"><i class="fas fa-times-circle"></i> Plumbing fixture count data table not found. Calculations disabled.</p>' : ''}
${G_CODE_DATA?.tables?.plumbingFixtureCounts?.notes ? `<div class="compliance-message info" style="margin-top:1rem"><i class="fas fa-info-circle"></i><div><h4>General Plumbing Notes from Table:</h4>${G_CODE_DATA.tables.plumbingFixtureCounts.notes.map(n=>`<p>${n}</p>`).join('')}</div></div>` : ''}
</div>
`;
}
initEvents() {
const projectSummary = this.app.getProjectData().data.projectSummary;
this.state.projectOccupancyGroup = projectSummary?.primaryOccupancyGroup || this.state.projectOccupancyGroup;
const ch3OL = this.app.getProjectData().data?.occupancy?.calculatedOccupantLoad;
this.state.occupantLoadForFixtures = ch3OL > 0 ? ch3OL : (projectSummary?.mainFunctionSpaceOccupantLoad || this.state.occupantLoadForFixtures || 0);
const occLoadInput = document.getElementById('ie-occupant-load');
if (occLoadInput) occLoadInput.value = this.state.occupantLoadForFixtures;
const tempOccSelect = document.getElementById('ie-temp-occupancy-group');
if (tempOccSelect) {
tempOccSelect.value = this.state.projectOccupancyGroup;
tempOccSelect.addEventListener('change', (e) => {
this.state.projectOccupancyGroup = e.target.value;
this.state.selectedFixtureType = "";
this.state.calculatedFixtures = null;
this.app.loadChapterById(this.chapterId); // Re-render module with new temp occupancy
});
} else {
const permOccDisplay = document.getElementById('ie-project-occupancy');
if (permOccDisplay) permOccDisplay.value = this.state.projectOccupancyGroup || 'Not Set (Select in Ch.5)';
}
occLoadInput?.addEventListener('input', (e) => {
this.state.occupantLoadForFixtures = parseInt(e.target.value) || 0;
this.state.calculatedFixtures = null;
this._updateFixtureResultDisplay();
});
const fixtureTypeSelect = document.getElementById('ie-fixture-type');
const calcButton = document.getElementById('calculate-fixtures');
fixtureTypeSelect?.addEventListener('change', (e) => {
this.state.selectedFixtureType = e.target.value;
this.state.calculatedFixtures = null;
this._updateFixtureResultDisplay();
const fixturesForOccupancy = G_CODE_DATA?.tables?.plumbingFixtureCounts?.data?.[this.state.projectOccupancyGroup];
if(calcButton) calcButton.disabled = !this.state.projectOccupancyGroup ||
!this.state.selectedFixtureType ||
!fixturesForOccupancy ||
!fixturesForOccupancy[this.state.selectedFixtureType];
});
if(fixtureTypeSelect && this.state.selectedFixtureType) fixtureTypeSelect.value = this.state.selectedFixtureType;
calcButton?.addEventListener('click', () => {
this._calculateFixturesAndUpdateDisplay();
});
const manualNotesEl = document.getElementById('ie-manual-notes');
if (manualNotesEl) manualNotesEl.value = this.state.manualIENotes;
manualNotesEl?.addEventListener('input', (e) => this.state.manualIENotes = e.target.value);
document.getElementById('save-ie-notes')?.addEventListener('click', () => {
this.saveData();
this.app.addOutputMessage('Ch 12/29: Interior Env/Plumbing notes saved.', 'success');
});
if (this.state.calculatedFixtures) { // If loaded data had a pre-calculated result
this._updateFixtureResultDisplay();
}
}
_updateFixtureResultDisplay() {
const resultDiv = document.getElementById('fixture-calculation-result');
if (resultDiv) {
resultDiv.innerHTML = this._getFixtureResultHtml();
}
}
_calculateFixturesAndUpdateDisplay() {
this.state.calculatedFixtures = null;
const ol = this.state.occupantLoadForFixtures;
const occGroup = this.state.projectOccupancyGroup;
const fixtureKey = this.state.selectedFixtureType;
const fixtureDataEntry = G_CODE_DATA?.tables?.plumbingFixtureCounts?.data?.[occGroup]?.[fixtureKey];
if (!occGroup || !fixtureKey) {
this.app.addOutputMessage("Ch 12/29: Occupancy Group and Fixture Type must be selected.", "warning");
this._updateFixtureResultDisplay();
return;
}
if (!fixtureDataEntry) {
this.app.addOutputMessage(`Ch 12/29: No fixture data found for ${occGroup} / ${fixtureKey}.`, "warning");
this._updateFixtureResultDisplay();
return;
}
if (ol <= 0) {
this.app.addOutputMessage("Ch 12/29: Occupant load must be > 0 for fixture calculation.", "warning");
this._updateFixtureResultDisplay();
return;
}
let count = 0;
let notes = fixtureDataEntry.notes || "";
let calcMethod = "";
if (fixtureDataEntry.count !== undefined) {
count = fixtureDataEntry.count;
calcMethod = "fixed count";
} else if (fixtureDataEntry.ratio_per_occupant) {
const ratioStr = fixtureDataEntry.ratio_per_occupant.toString().toLowerCase();
if (ratioStr.startsWith("1 per ")) {
const divisor = parseFloat(ratioStr.replace("1 per ", ""));
if (!isNaN(divisor) && divisor > 0) {
count = Math.ceil(ol / divisor);
calcMethod = `ratio (1 per ${divisor})`;
} else {
notes += " (Invalid ratio divisor)";
count = "Error";
}
} else if (ratioStr === "see note a" && occGroup === "B" && notes) { // Example specific handling for 'see note a'
const tiers = notes.match(/(\d+):(\d+)-(\d+)=(\d+)/g) || [];
const overRateMatch = notes.match(/over (\d+),?\s*1 per (\d+)/i);
let baseCountAtThreshold = 0;
let overThreshold = -1;
let rateOverThreshold = -1;
let foundTier = false;
if (overRateMatch) {
overThreshold = parseInt(overRateMatch[1]);
rateOverThreshold = parseInt(overRateMatch[2]);
}
for (const tier of tiers) {
const parts = tier.match(/(\d+):(\d+)-(\d+)=(\d+)/);
if (!parts) continue;
const minOL = parseInt(parts[2]);
const maxOL = parseInt(parts[3]);
const tierCount = parseInt(parts[4]);
if (ol >= minOL && ol <= maxOL) {
count = tierCount;
foundTier = true;
break;
}
if (maxOL === overThreshold) { // If this tier ends where the 'over' rate begins
baseCountAtThreshold = tierCount;
}
}
if (!foundTier && overThreshold > 0 && rateOverThreshold > 0 && ol > overThreshold) {
if (baseCountAtThreshold === 0 && tiers.length > 0) { // Try to find base if not directly found
const lastTierParts = tiers[tiers.length - 1].match(/(\d+):(\d+)-(\d+)=(\d+)/);
if (lastTierParts && parseInt(lastTierParts[3]) === overThreshold) {
baseCountAtThreshold = parseInt(lastTierParts[4]);
} else {
// Fallback if the 'over' threshold isn't explicitly the end of a tier
baseCountAtThreshold = 1; // Or some other default, this is tricky without exact data structure
notes += " (Could not accurately determine base count for 'over' calculation)";
}
}
count = baseCountAtThreshold + Math.ceil((ol - overThreshold) / rateOverThreshold);
} else if (!foundTier && !(ol > overThreshold && rateOverThreshold > 0)) {
notes += " (Occupant load outside defined tiered ranges in notes.)";
count = 1; // Default to 1 if no tier matches
}
calcMethod = "tiered note";
} else {
notes += ` (Ratio format '${fixtureDataEntry.ratio_per_occupant}' not automatically handled.)`;
count = "Check Notes";
calcMethod = "unhandled ratio";
}
} else {
notes = notes || "Requirement described in notes or needs manual check.";
count = "Check Notes";
calcMethod = "notes only";
}
if (typeof count === 'number' && fixtureDataEntry.min_count !== undefined && count < fixtureDataEntry.min_count) {
count = fixtureDataEntry.min_count;
notes += (notes ? "; " : "") + `Adjusted to min count of ${fixtureDataEntry.min_count}.`;
}
this.state.calculatedFixtures = { count: count, notes: notes.trim(), method: calcMethod };
this._updateFixtureResultDisplay();
this.saveData(); // Save after calculation
}
saveData() {
// Save user-selectable state and the calculated fixture result if it exists
const data = {
occupantLoadForFixtures: this.state.occupantLoadForFixtures,
selectedFixtureType: this.state.selectedFixtureType,
manualIENotes: this.state.manualIENotes,
calculatedFixtures: this.state.calculatedFixtures // Persist the calculation result
};
if (document.getElementById('ie-temp-occupancy-group')) {
data.projectOccupancyGroup = this.state.projectOccupancyGroup; // Save temp selection if used
}
this.app.updateProjectData(this.chapterId, data);
return data;
}
loadData(data) {
this.state.occupantLoadForFixtures = data.occupantLoadForFixtures || 0;
this.state.selectedFixtureType = data.selectedFixtureType || "";
this.state.manualIENotes = data.manualIENotes || "";
this.state.calculatedFixtures = data.calculatedFixtures || null; // Load persisted calculation
this.state.projectOccupancyGroup = data.projectOccupancyGroup || this.app.getProjectData().data.projectSummary?.primaryOccupancyGroup || "";
const ch3OL = this.app.getProjectData().data?.occupancy?.calculatedOccupantLoad;
const projectSummaryOL = this.app.getProjectData().data.projectSummary?.mainFunctionSpaceOccupantLoad;
if (this.state.occupantLoadForFixtures === 0) {
this.state.occupantLoadForFixtures = ch3OL > 0 ? ch3OL : (projectSummaryOL || 0);
}
const occupantLoadInput = document.getElementById('ie-occupant-load');
if (!occupantLoadInput) return;
occupantLoadInput.value = this.state.occupantLoadForFixtures;
const manualNotesEl = document.getElementById('ie-manual-notes');
if (manualNotesEl) manualNotesEl.value = this.state.manualIENotes;
const tempOccSelect = document.getElementById('ie-temp-occupancy-group');
if (tempOccSelect) {
tempOccSelect.value = this.state.projectOccupancyGroup;
} else {
const permOccDisplay = document.getElementById('ie-project-occupancy');
if (permOccDisplay) permOccDisplay.value = this.state.projectOccupancyGroup || 'Not Set (Select in Ch.5)';
}
const fixtureSelect = document.getElementById('ie-fixture-type');
if (fixtureSelect) fixtureSelect.value = this.state.selectedFixtureType;
// If there's a loaded calculation, display it. Otherwise, check if we can calculate.
if (this.state.calculatedFixtures) {
this._updateFixtureResultDisplay();
} else {
const fixturesForOccupancy = G_CODE_DATA?.tables?.plumbingFixtureCounts?.data?.[this.state.projectOccupancyGroup];
const isCalcValid = this.state.projectOccupancyGroup &&
this.state.selectedFixtureType &&
this.state.occupantLoadForFixtures > 0 &&
fixturesForOccupancy &&
fixturesForOccupancy[this.state.selectedFixtureType];
if (isCalcValid) {
this._calculateFixturesAndUpdateDisplay(); // Attempt to calculate if not pre-loaded
} else {
this._updateFixtureResultDisplay(); // Clear or show placeholder
}
}
const calcButton = document.getElementById('calculate-fixtures');
if(calcButton) {
const fixturesForOccupancy = G_CODE_DATA?.tables?.plumbingFixtureCounts?.data?.[this.state.projectOccupancyGroup];
calcButton.disabled = !(this.state.projectOccupancyGroup &&
this.state.selectedFixtureType &&
fixturesForOccupancy &&
fixturesForOccupancy[this.state.selectedFixtureType]);
}
}
}
class EnergyEfficiencyModule {
constructor(app) {
this.app = app;
this.chapterId = 'energy-efficiency';
this.state = {
climateZone: "",
compliancePath: "prescriptive",
notes: ""
};
}
render() {
return `
<div class="energy-efficiency-module">
<h2>Chapter 13: Energy Efficiency</h2>
<div class="form-section">
<h3>Energy Code Parameters <i class="fas fa-info-circle" title="Track general energy efficiency parameters for the project."></i></h3>
<p>References International Energy Conservation Code (IECC) or state codes for thermal envelope, mechanical, lighting, power.</p>
<p><em>This module is a placeholder. Below are fields for general tracking.</em></p>
<div class="form-row">
<div class="form-group">
<label for="ee-climate-zone">Project Climate Zone (IECC/ASHRAE 90.1):</label>
<input type="text" id="ee-climate-zone" value="${this.state.climateZone}" placeholder="e.g., 4A, 5B">
</div>
<div class="form-group">
<label for="ee-compliance-path">Chosen Compliance Path:</label>
<select id="ee-compliance-path">
<option value="prescriptive" ${this.state.compliancePath === 'prescriptive' ? 'selected' : ''}>Prescriptive Path</option>
<option value="performance" ${this.state.compliancePath === 'performance' ? 'selected' : ''}>Total Building Performance</option>
<option value="ashrae901" ${this.state.compliancePath === 'ashrae901' ? 'selected' : ''}>ASHRAE 90.1</option>
<option value="other" ${this.state.compliancePath === 'other' ? 'selected' : ''}>Other</option>
</select>
</div>
</div>
<div class="form-group">
<label for="ee-notes">Energy Efficiency Notes:</label>
<textarea id="ee-notes" rows="3" placeholder="e.g., Key R-values, U-factors, system efficiencies...">${this.state.notes}</textarea>
</div>
<button id="save-energy-data"><i class="fas fa-leaf"></i> Save Energy Data</button>
</div>
</div>
`;
}
initEvents() {
const climateZoneEl = document.getElementById('ee-climate-zone');
const compliancePathEl = document.getElementById('ee-compliance-path');
const notesEl = document.getElementById('ee-notes');
const saveBtn = document.getElementById('save-energy-data');
if(climateZoneEl) climateZoneEl.value = this.state.climateZone;
if(compliancePathEl) compliancePathEl.value = this.state.compliancePath;
if(notesEl) notesEl.value = this.state.notes;
climateZoneEl?.addEventListener('input', (e) => this.state.climateZone = e.target.value);
compliancePathEl?.addEventListener('change', (e) => this.state.compliancePath = e.target.value);
notesEl?.addEventListener('input', (e) => this.state.notes = e.target.value);
saveBtn?.addEventListener('click', () => {
this.saveData();
this.app.addOutputMessage('Ch 13: Energy Efficiency data saved.', 'success');
});
}
saveData() {
const data = { ...this.state };
this.app.updateProjectData(this.chapterId, data);
return data;
}
loadData(data) {
this.state = { ...this.state, ...data };
const climateZoneEl = document.getElementById('ee-climate-zone');
if (climateZoneEl) {
climateZoneEl.value = this.state.climateZone;
const compliancePathEl = document.getElementById('ee-compliance-path');
if (compliancePathEl) compliancePathEl.value= this.state.compliancePath;
const notesEl = document.getElementById('ee-notes');
if (notesEl) notesEl.value = this.state.notes;
}
}
}
class ExteriorWallsModule {
constructor(app) {
this.app = app;
this.chapterId = 'exterior-walls';
this.state = {
projectOccupancyGroup: this.app.getProjectData().data.projectSummary?.primaryOccupancyGroup || "",
fireSeparationDistanceFt: null,
selectedFsdRangeKey: "",
requiredRatingInfo: null, // Will store { rating_hours: X, opening_protection_ref: "...", notes: "..." }
wallNotes: ""
};
}
_getWallRatingResultHtml() {
if (!this.state.requiredRatingInfo) return "";
const ratingVal = this.state.requiredRatingInfo.rating_hours;
const ratingDisplay = typeof ratingVal === 'number' ? `${ratingVal} hours` : ratingVal;
const occDisplay = this.state.projectOccupancyGroup || "N/A";
const fsdDisplay = this.state.fireSeparationDistanceFt !== null
? `${this.state.fireSeparationDistanceFt} ft (actual)`
: (this.state.selectedFsdRangeKey ? this._formatFsdRangeKey(this.state.selectedFsdRangeKey) + ' ft (range)' : 'N/A');
return `
<div class="compliance-message info">
<i class="fas fa-info-circle"></i>
<div>
<h4>Required Fire-Resistance Rating for Nonbearing Exterior Wall (Table 602 data):</h4>
<p>For Occupancy <strong>${occDisplay}</strong>, FSD: <strong>${fsdDisplay}</strong></p>
<p><strong>Required Rating: ${ratingDisplay}</strong></p>
${this.state.requiredRatingInfo.opening_protection_ref ? `<p><small>Opening Protection: Refer to ${this.state.requiredRatingInfo.opening_protection_ref}</small></p>` : ''}
${this.state.requiredRatingInfo.notes ? `<p><small>Notes: ${this.state.requiredRatingInfo.notes}</small></p>` : ''}
</div>
</div>
`;
}
_formatFsdRangeKey(key) {
if (!key) return "N/A";
return key.replace(/_/g, ' ').replace("to under", "to <").replace("and over", "≥");
}
render() {
const projectSummary = this.app.getProjectData().data.projectSummary;
this.state.projectOccupancyGroup = projectSummary?.primaryOccupancyGroup || this.state.projectOccupancyGroup;
const occupancyClassData = G_CODE_DATA?.tables?.occupancyClassifications?.data || {};
const extWallRatingTable = G_CODE_DATA?.tables?.exteriorWallRatingRequirements?.data;
let occupancySelectorHtml = `<input type="text" id="ew-project-occupancy" value="${this.state.projectOccupancyGroup || 'Not Set (Select in Ch.5)'}" readonly class="form-control-plaintext">`;
let occupancyWarning = '';
if (!this.state.projectOccupancyGroup) {
let occOptions = '<option value="">-- Select Occupancy --</option>';
if (extWallRatingTable) {
Object.keys(extWallRatingTable).sort().forEach(og => {
const desc = occupancyClassData[og]?.description || og;
occOptions += `<option value="${og}">${og} - ${desc}</option>`;
});
} else if (G_CODE_DATA) {
occOptions = '<option value="">-- Ext. Wall Table Missing --</option>';
}
occupancySelectorHtml = `<select id="ew-temp-occupancy-group">${occOptions}</select><small>Primary occ. not set. Select here temporarily.</small>`;
} else if (extWallRatingTable && !extWallRatingTable[this.state.projectOccupancyGroup]) {
occupancyWarning = `<small class="warning-color-text">Warning: No exterior wall rating data found for primary occupancy '${this.state.projectOccupancyGroup}' in JSON table.</small>`;
}
let fsdOptionsHtml = '<option value="">-- Select FSD Range --</option>';
const rangesForOccupancy = extWallRatingTable?.[this.state.projectOccupancyGroup];
if (rangesForOccupancy) {
const sortedRanges = Object.keys(rangesForOccupancy).sort((a,b) => parseFloat(a.split('_')[0]) - parseFloat(b.split('_')[0]) );
sortedRanges.forEach(fsdRangeKey => {
const displayName = this._formatFsdRangeKey(fsdRangeKey) + " ft";
fsdOptionsHtml += `<option value="${fsdRangeKey}" ${this.state.selectedFsdRangeKey === fsdRangeKey ? 'selected' : ''}>${displayName}</option>`;
});
} else if (this.state.projectOccupancyGroup) {
fsdOptionsHtml = '<option value="">-- No FSD Ranges for this Occupancy --</option>';
} else if (!this.state.projectOccupancyGroup && !document.getElementById('ew-temp-occupancy-group')) {
fsdOptionsHtml = '<option value="">-- Select Occupancy First --</option>';
}
let resultHtml = this._getWallRatingResultHtml();
return `
<div class="exterior-walls-module">
<h2>Chapter 14: Exterior Walls</h2>
<p>Covers fire-resistance (IBC Table 602 based on FSD & Occupancy), materials, weather protection, openings.</p>
<div class="form-section">
<h3>Nonbearing Exterior Wall Fire-Resistance (Table 602) <i class="fas fa-info-circle" title="Determine fire-resistance rating based on occupancy and Fire Separation Distance (FSD)."></i></h3>
<div class="form-group">
<label>Project Occupancy Group:</label>
${occupancySelectorHtml}
${occupancyWarning}
</div>
<div class="form-group">
<label for="ew-fsd-actual">Actual Fire Separation Distance (ft):</label>
<input type="number" id="ew-fsd-actual" min="0" step="any" value="${this.state.fireSeparationDistanceFt === null ? '' : this.state.fireSeparationDistanceFt}" placeholder="e.g., 7.5">
<small>Enter the actual FSD to find the corresponding range, or select a range below.</small>
</div>
<div class="form-group">
<label for="ew-fsd-range-select">Or Select FSD Range (from Table 602 data):</label>
<select id="ew-fsd-range-select" ${!this.state.projectOccupancyGroup || !rangesForOccupancy ? 'disabled' : ''}>${fsdOptionsHtml}</select>
</div>
<button id="calculate-ext-wall-rating" ${!this.state.projectOccupancyGroup || !(this.state.selectedFsdRangeKey || this.state.fireSeparationDistanceFt !== null) ? 'disabled' : ''}><i class="fas fa-shield-alt"></i> Determine Wall Rating</button>
</div>
<div class="form-section">
<h3>Wall Rating Result <i class="fas fa-info-circle" title="Displays the required fire-resistance rating for the nonbearing exterior wall."></i></h3>
<div id="ext-wall-rating-result" style="margin-top: 1.5rem;">${resultHtml}</div>
</div>
<div class="form-section">
<h3>Notes <i class="fas fa-info-circle" title="General notes on exterior wall system."></i></h3>
<div class="form-group">
<label for="ew-notes">Exterior Wall System Notes:</label>
<textarea id="ew-notes" rows="3" placeholder="e.g., Wall assembly, weather barrier, opening protection...">${this.state.wallNotes}</textarea>
</div>
<button id="save-ew-notes"><i class="fas fa-save"></i> Save Notes</button>
</div>
${!G_CODE_DATA ? '<p class="compliance-message error"><i class="fas fa-times-circle"></i> Code Data not loaded.</p>' : ''}
${G_CODE_DATA && !extWallRatingTable ? '<p class="compliance-message warning"><i class="fas fa-exclamation-triangle"></i> Exterior Wall Rating Requirements table (Table 602 data) not found in JSON.</p>' : ''}
${G_CODE_DATA?.tables?.exteriorWallRatingRequirements?.notes ? `<div class="compliance-message info" style="margin-top:1rem"><i class="fas fa-info-circle"></i><div><h4>General Notes from Table 602 Data:</h4>${G_CODE_DATA.tables.exteriorWallRatingRequirements.notes.map(n=>`<p>${n}</p>`).join('')}</div></div>` : ''}
</div>
`;
}
initEvents() {
this.state.projectOccupancyGroup = this.app.getProjectData().data.projectSummary?.primaryOccupancyGroup || this.state.projectOccupancyGroup;
const tempOccSelect = document.getElementById('ew-temp-occupancy-group');
if (tempOccSelect) {
tempOccSelect.value = this.state.projectOccupancyGroup;
tempOccSelect.addEventListener('change', (e) => {
this.state.projectOccupancyGroup = e.target.value;
this.state.selectedFsdRangeKey = "";
this.state.requiredRatingInfo = null;
this.state.fireSeparationDistanceFt = null;
this.app.loadChapterById(this.chapterId); // Re-render
});
} else {
const permOccDisplay = document.getElementById('ew-project-occupancy');
if(permOccDisplay) permOccDisplay.value = this.state.projectOccupancyGroup || 'Not Set (Select in Ch.5)';
}
const fsdActualInput = document.getElementById('ew-fsd-actual');
fsdActualInput?.addEventListener('input', (e) => {
const val = e.target.value;
this.state.fireSeparationDistanceFt = val === "" ? null : parseFloat(val);
const foundKey = this.state.fireSeparationDistanceFt !== null ? this._findFsdRangeKey(this.state.fireSeparationDistanceFt) : "";
this.state.selectedFsdRangeKey = foundKey || "";
const fsdRangeSelect = document.getElementById('ew-fsd-range-select');
if (fsdRangeSelect) fsdRangeSelect.value = this.state.selectedFsdRangeKey;
this.state.requiredRatingInfo = null; // Clear old result
this._updateWallRatingResultDisplay();
this._updateCalcButtonState();
});
const fsdRangeSelect = document.getElementById('ew-fsd-range-select');
fsdRangeSelect?.addEventListener('change', (e) => {
this.state.selectedFsdRangeKey = e.target.value;
this.state.fireSeparationDistanceFt = null; // Clear actual if range is selected
if(fsdActualInput) fsdActualInput.value = "";
this.state.requiredRatingInfo = null; // Clear old result
this._updateWallRatingResultDisplay();
this._updateCalcButtonState();
});
if(fsdRangeSelect && this.state.selectedFsdRangeKey) fsdRangeSelect.value = this.state.selectedFsdRangeKey;
if(fsdActualInput) fsdActualInput.value = this.state.fireSeparationDistanceFt === null ? '' : this.state.fireSeparationDistanceFt;
document.getElementById('calculate-ext-wall-rating')?.addEventListener('click', () => {
this._calculateWallRatingAndUpdateDisplay();
});
const wallNotesEl = document.getElementById('ew-notes');
if (wallNotesEl) wallNotesEl.value = this.state.wallNotes;
wallNotesEl?.addEventListener('input', (e) => this.state.wallNotes = e.target.value);
document.getElementById('save-ew-notes')?.addEventListener('click', () => {
this.saveData();
this.app.addOutputMessage('Ch 14: Exterior Wall notes saved.', 'success');
});
this._updateCalcButtonState();
if (this.state.requiredRatingInfo) { // If loaded data had a rating
this._updateWallRatingResultDisplay();
}
}
_updateCalcButtonState() {
const calcButton = document.getElementById('calculate-ext-wall-rating');
if (calcButton) {
const isEnabled = !!this.state.projectOccupancyGroup &&
(this.state.selectedFsdRangeKey || this.state.fireSeparationDistanceFt !== null);
calcButton.disabled = !isEnabled;
}
}
_updateWallRatingResultDisplay() {
const resultDiv = document.getElementById('ext-wall-rating-result');
if (resultDiv) {
resultDiv.innerHTML = this._getWallRatingResultHtml();
}
}
_findFsdRangeKey(actualFsd) {
const extWallRatingTable = G_CODE_DATA?.tables?.exteriorWallRatingRequirements?.data;
const occData = extWallRatingTable?.[this.state.projectOccupancyGroup];
if (!occData || actualFsd === null || actualFsd < 0) return null;
const sortedRanges = Object.keys(occData).sort((a, b) => parseFloat(a.split('_')[0]) - parseFloat(b.split('_')[0]));
for (const rangeKey of sortedRanges) {
const parts = rangeKey.split('_');
let min, max;
let inclusiveMin = true;
let exclusiveMax = false;
if (parts.includes("and") && parts.includes("over")) { // e.g. "30_and_over"
min = parseFloat(parts[0]);
max = Infinity;
} else if (parts.includes("to") && parts.includes("under")) { // e.g. "5_to_under_10"
min = parseFloat(parts[0]);
max = parseFloat(parts[parts.indexOf("under") + 1]);
exclusiveMax = true;
} else { // Fallback or other formats not handled
continue;
}
if (isNaN(min)) continue;
const checkMin = inclusiveMin ? actualFsd >= min : actualFsd > min;
const checkMax = (max === Infinity) ? true : (exclusiveMax ? actualFsd < max : actualFsd <= max);
if (checkMin && checkMax) {
return rangeKey;
}
}
return null; // No matching range found
}
_calculateWallRatingAndUpdateDisplay() {
this.state.requiredRatingInfo = null;
const occGroup = this.state.projectOccupancyGroup;
let fsdKeyToLookup = this.state.selectedFsdRangeKey;
if (this.state.fireSeparationDistanceFt !== null && this.state.fireSeparationDistanceFt >= 0) {
const foundKey = this._findFsdRangeKey(this.state.fireSeparationDistanceFt);
if (foundKey) {
fsdKeyToLookup = foundKey;
if (this.state.selectedFsdRangeKey !== foundKey) { // Sync dropdown if actual input found a match
this.state.selectedFsdRangeKey = foundKey;
const fsdRangeSelect = document.getElementById('ew-fsd-range-select');
if(fsdRangeSelect) fsdRangeSelect.value = foundKey;
}
} else {
this.app.addOutputMessage(`Ch 14: No FSD range in Table 602 data matches actual FSD ${this.state.fireSeparationDistanceFt}ft for Occupancy ${occGroup}.`, 'warning');
this._updateWallRatingResultDisplay(); // Display no result
return;
}
}
if (!occGroup || !fsdKeyToLookup) {
this.app.addOutputMessage(`Ch 14: Occupancy Group and Fire Separation Distance must be selected or determined.`, 'warning');
this._updateWallRatingResultDisplay(); // Display no result
return;
}
const extWallRatingTable = G_CODE_DATA?.tables?.exteriorWallRatingRequirements?.data;
if (extWallRatingTable) {
this.state.requiredRatingInfo = extWallRatingTable[occGroup]?.[fsdKeyToLookup] || null;
if(!this.state.requiredRatingInfo) {
this.app.addOutputMessage(`Ch 14: No rating data found for Occ: ${occGroup}, FSD Range: ${this._formatFsdRangeKey(fsdKeyToLookup)}. Check JSON data.`, 'warning');
}
} else {
this.app.addOutputMessage(`Ch 14: ExteriorWallRatingRequirements table not found in code data.`, 'error');
}
this.saveData(); // Save after calculation
this._updateWallRatingResultDisplay();
}
saveData() {
// Save user-selectable state and the derived selectedFsdRangeKey if it was set by actualFsd
const data = {
fireSeparationDistanceFt: this.state.fireSeparationDistanceFt,
selectedFsdRangeKey: this.state.selectedFsdRangeKey,
wallNotes: this.state.wallNotes,
projectOccupancyGroup: document.getElementById('ew-temp-occupancy-group') ? this.state.projectOccupancyGroup : undefined,
// Do not save requiredRatingInfo, it's derived
};
this.app.updateProjectData(this.chapterId, data);
return data;
}
loadData(data) {
this.state.fireSeparationDistanceFt = data.fireSeparationDistanceFt !== undefined ? data.fireSeparationDistanceFt : null;
this.state.selectedFsdRangeKey = data.selectedFsdRangeKey || "";
this.state.wallNotes = data.wallNotes || "";
this.state.projectOccupancyGroup = data.projectOccupancyGroup || this.app.getProjectData().data.projectSummary?.primaryOccupancyGroup || "";
this.state.requiredRatingInfo = null; // Reset for recalculation
const fsdActualInput = document.getElementById('ew-fsd-actual');
if (!fsdActualInput) return;
fsdActualInput.value = this.state.fireSeparationDistanceFt === null ? '' : this.state.fireSeparationDistanceFt;
const fsdRangeSelect = document.getElementById('ew-fsd-range-select');
if (fsdRangeSelect) fsdRangeSelect.value = this.state.selectedFsdRangeKey;
const wallNotesEl = document.getElementById('ew-notes');
if (wallNotesEl) wallNotesEl.value = this.state.wallNotes;
const tempOccSelect = document.getElementById('ew-temp-occupancy-group');
if (tempOccSelect) {
tempOccSelect.value = this.state.projectOccupancyGroup;
} else {
const permOccDisplay = document.getElementById('ew-project-occupancy');
if (permOccDisplay) permOccDisplay.value = this.state.projectOccupancyGroup || 'Not Set (Select in Ch.5)';
}
// If enough data is loaded to attempt a calculation/display, do it
if (this.state.projectOccupancyGroup && (this.state.selectedFsdRangeKey || this.state.fireSeparationDistanceFt !== null)) {
this._calculateWallRatingAndUpdateDisplay();
} else {
this._updateWallRatingResultDisplay(); // Clear display if not enough info
}
this._updateCalcButtonState();
}
}
class RoofAssembliesModule {
constructor(app) {
this.app = app;
this.chapterId = 'roof-assemblies';
this.state = {
roofCoveringClass: "B",
roofSlope: "3:12",
hasRooftopStructures: false,
notes: ""
};
}
render() {
const projectConstructionType = this.app.getProjectData().data.projectSummary?.constructionType || "Not Set";
return `
<div class="roof-assemblies-module">
<h2>Chapter 15: Roof Assemblies and Rooftop Structures</h2>
<div class="form-section">
<h3>Roof Parameters <i class="fas fa-info-circle" title="Define key characteristics of the roof assembly and identify rooftop structures."></i></h3>
<p>Covers weather protection, fire classification (ASTM E108/UL 790), drainage, rooftop structures.</p>
<p><em>This is a placeholder. Below are general tracking fields.</em></p>
<p>Project Construction Type: <strong>${projectConstructionType}</strong> (influences roof covering requirements per IBC Table 1505.1)</p>
<div class="form-row">
<div class="form-group">
<label for="roof-covering-class">Roof Covering Fire Classification (Class A, B, C, or Non-classified):</label>
<input type="text" id="roof-covering-class" value="${this.state.roofCoveringClass}" placeholder="Class A, B, C...">
<small>See IBC Table 1505.1 based on construction type and fire separation distance.</small>
</div>
<div class="form-group">
<label for="roof-slope">Roof Slope (e.g., 4:12, 0.25:12 for low-slope):</label>
<input type="text" id="roof-slope" value="${this.state.roofSlope}" placeholder="e.g., 4:12">
</div>
</div>
<div class="form-group">
<label for="has-rooftop-structures">Rooftop structures present (IBC 1510)?</label>
<select id="has-rooftop-structures">
<option value="false" ${!this.state.hasRooftopStructures ? 'selected' : ''}>No</option>
<option value="true" ${this.state.hasRooftopStructures ? 'selected' : ''}>Yes</option>
</select>
</div>
</div>
<div class="form-section">
<h3>Notes <i class="fas fa-info-circle" title="General notes on roof assembly and rooftop structures."></i></h3>
<div class="form-group">
<label for="roof-notes">Roof Assembly & Rooftop Structure Notes:</label>
<textarea id="roof-notes" rows="3" placeholder="e.g., Material, insulation, drainage, penthouse details...">${this.state.notes}</textarea>
</div>
<button id="save-roof-data"><i class="fas fa-home"></i> Save Roof Data</button>
</div>
</div>
`;
}
initEvents() {
const roofClassEl = document.getElementById('roof-covering-class');
const roofSlopeEl = document.getElementById('roof-slope');
const rooftopStructEl = document.getElementById('has-rooftop-structures');
const roofNotesEl = document.getElementById('roof-notes');
const saveBtn = document.getElementById('save-roof-data');
if(roofClassEl) roofClassEl.value = this.state.roofCoveringClass;
if(roofSlopeEl) roofSlopeEl.value = this.state.roofSlope;
if(rooftopStructEl) rooftopStructEl.value = this.state.hasRooftopStructures.toString();
if(roofNotesEl) roofNotesEl.value = this.state.notes;
roofClassEl?.addEventListener('input', (e) => this.state.roofCoveringClass = e.target.value);
roofSlopeEl?.addEventListener('input', (e) => this.state.roofSlope = e.target.value);
rooftopStructEl?.addEventListener('change', (e) => this.state.hasRooftopStructures = e.target.value === 'true');
roofNotesEl?.addEventListener('input', (e) => this.state.notes = e.target.value);
saveBtn?.addEventListener('click', () => {
this.saveData();
this.app.addOutputMessage('Ch 15: Roof Assemblies data saved.', 'success');
});
}
saveData() {
const data = { ...this.state };
this.app.updateProjectData(this.chapterId, data);
return data;
}
loadData(data) {
this.state = { ...this.state, ...data };
const roofClassEl = document.getElementById('roof-covering-class');
if (roofClassEl) {
roofClassEl.value = this.state.roofCoveringClass;
const roofSlopeEl = document.getElementById('roof-slope');
if (roofSlopeEl) roofSlopeEl.value = this.state.roofSlope;
const rooftopStructEl = document.getElementById('has-rooftop-structures');
if (rooftopStructEl) rooftopStructEl.value = this.state.hasRooftopStructures.toString();
const roofNotesEl = document.getElementById('roof-notes');
if (roofNotesEl) roofNotesEl.value = this.state.notes;
}
}
}
class SummaryModule {
constructor(app) {
this.app = app;
this.chapterId = 'summary';
this.state = {
// Initial placeholder content
summaryHtmlContent: '<p><em>Click "Generate/Refresh Summary" to see the latest project data and compliance checks.</em></p>'
};
}
render() {
return `
<div class="summary-module">
<h2>Project Summary & Compliance</h2>
<div class="form-section">
<button id="generate-summary-btn" style="background-color: var(--success-color); color: white;"><i class="fas fa-sync-alt"></i> Generate/Refresh Summary</button>
</div>
<div id="summary-output-section" class="form-section" style="background-color: var(--output-panel-bg); border: 1px solid var(--nav-border); padding: 1.5rem; border-radius: var(--border-radius);">
${this.state.summaryHtmlContent}
</div>
<div class="form-section" id="summary-export-section" style="display: none; margin-top: 1rem;">
<h3>Export Summary</h3>
<button id="export-summary-pdf-btn"><i class="fas fa-file-pdf"></i> Export Summary to PDF</button>
</div>
</div>
`;
}
initEvents() {
document.getElementById('generate-summary-btn')?.addEventListener('click', () => this.generateAndDisplaySummary());
document.getElementById('export-summary-pdf-btn')?.addEventListener('click', () => this.exportSummaryToPdf());
const summaryOutput = document.getElementById('summary-output-section');
if (summaryOutput) summaryOutput.innerHTML = this.state.summaryHtmlContent;
const exportSection = document.getElementById('summary-export-section');
if (exportSection && this.state.summaryHtmlContent && !this.state.summaryHtmlContent.includes("Click \"Generate/Refresh Summary\"")) {
exportSection.style.display = 'block';
}
}
generateAndDisplaySummary() {
this.app.updateStatus('Generating summary...');
const projectDataContainer = this.app.getProjectData();
const projectInfo = { name: projectDataContainer.name, location: projectDataContainer.location };
const projectSummaryData = projectDataContainer.data.projectSummary || {};
let codeEdition = G_CODE_DATA?.codeEdition || "Not Specified";
let html = `<h3 style="color: var(--primary-color);">Project: ${projectInfo.name || 'N/A'}</h3>`;
html += `<p><strong>Location:</strong> ${projectInfo.location || 'N/A'}</p>`;
html += `<p><strong>Applicable Code:</strong> ${codeEdition}</p>`;
html += `<hr style="border-color: var(--nav-border);">`;
html += `<h4 style="color: var(--primary-color);">Key Parameters:</h4>`;
html += `<p><strong>Predominant Occupancy Group (Ch.5):</strong> ${projectSummaryData.primaryOccupancyGroup || '<span style="color: var(--warning-color);">Not Set</span>'}</p>`;
html += `<p><strong>Construction Type (Ch.5):</strong> ${projectSummaryData.constructionType || '<span style="color: var(--warning-color);">Not Set</span>'}</p>`;
html += `<p><strong>Sprinklered (Ch.9):</strong> ${projectSummaryData.isSprinklered ? `Yes (${projectSummaryData.sprinklerStandard || 'NFPA 13'})` : 'No'}</p>`;
html += `<p><strong>Fire Alarm (Ch.9):</strong> ${projectSummaryData.hasFireAlarm ? `Yes (${projectSummaryData.fireAlarmType ? projectSummaryData.fireAlarmType.replace(/_/g, ' ') : 'Type N/A'})` : 'No'}</p>`;
html += `<hr style="border-color: var(--nav-border);">`;
html += `<h4 style="color: var(--primary-color);">Chapter 5: Heights & Areas</h4>`;
if (projectSummaryData.constructionType && projectSummaryData.primaryOccupancyGroup && projectSummaryData.areaPerStory !== undefined) {
const formatVal = (val, precision = 0) => {
if (val === "NP") return "Not Permitted";
if (val === Number.POSITIVE_INFINITY) return "No Limit";
if (typeof val === "number") return val.toLocaleString(undefined, { maximumFractionDigits: precision });
return "N/A";
};
const complianceText = (compliant) => {
if (compliant === null || compliant === undefined) return '<span style="color: var(--warning-color);"> (Not Checked)</span>';
return compliant ? ' <i class="fas fa-check-circle" style="color: var(--success-color);"></i> PASS' : ' <i class="fas fa-times-circle" style="color: var(--danger-color);"></i> FAIL';
};
html += `<p>Proposed Area/Story: ${formatVal(projectSummaryData.areaPerStory)} sq ft`;
html += ` (Allowable: ${formatVal(projectSummaryData.allowableAreaPerStory)}) ${complianceText(projectSummaryData.areaCompliant)}</p>`;
html += `<p>Proposed Stories: ${projectSummaryData.numStories || '0'}`;
html += ` (Allowable: ${formatVal(projectSummaryData.allowableStories)}) ${complianceText(projectSummaryData.storiesCompliant)}</p>`;
html += `<p>Proposed Height: ${projectSummaryData.actualHeightFt || '0'} ft`;
html += ` (Allowable: ${formatVal(projectSummaryData.allowableHeightFt)}) ${complianceText(projectSummaryData.heightCompliant)}</p>`;
} else {
html += `<p><em style="color: var(--warning-color);">Input parameters in Ch.5 and run "Calculate Allowable Heights & Areas".</em></p>`;
}
html += `<hr style="border-color: var(--nav-border);">`;
html += `<h4 style="color: var(--primary-color);">Chapter 3/10: Occupancy & Means of Egress</h4>`;
const mainOccupantLoad = projectSummaryData.egress_occupantLoad !== undefined ? projectSummaryData.egress_occupantLoad : projectSummaryData.mainFunctionSpaceOccupantLoad;
if (mainOccupantLoad !== undefined && mainOccupantLoad >= 0) { // Allow OL=0 to be shown
html += `<p>Calculated Occupant Load (for egress/main space): <strong>${mainOccupantLoad}</strong></p>`;
} else {
html += `<p><em style="color: var(--warning-color);">Occupant load not available from Ch.3 or Ch.10 inputs.</em></p>`;
}
if (projectSummaryData.egress_minNumberOfExits !== undefined) {
html += `<p>Min. Number of Exits: <strong>${projectSummaryData.egress_minNumberOfExits === 'N/A' ? 'N/A (Check Ch.10)' : projectSummaryData.egress_minNumberOfExits}</strong></p>`;
html += `<p>Required Egress Width (Stairs): <strong>${projectSummaryData.egress_requiredWidthStairs?.toFixed(1) || 'N/A'} inches</strong></p>`;
html += `<p>Required Egress Width (Other): <strong>${projectSummaryData.egress_requiredWidthOther?.toFixed(1) || 'N/A'} inches</strong></p>`;
html += `<p>Common Path Travel Limit: <strong>${projectSummaryData.egress_commonPathTravelLimit || 'N/A'} ft</strong></p>`;
html += `<p>Exit Access Travel Limit: <strong>${projectSummaryData.egress_exitAccessTravelDistanceLimit || 'N/A'} ft</strong></p>`;
} else {
html += `<p><em style="color: var(--warning-color);">Egress requirements not calculated in Ch.10.</em></p>`;
}
html += `<hr style="border-color: var(--nav-border);">`;
html += `<h4 style="color: var(--primary-color);">Other Chapter Highlights (Illustrative):</h4>`;
const ch1Data = projectDataContainer.data['scope-administration'];
if (ch1Data && (ch1Data.permitRequired !== undefined || ch1Data.notes)) {
html += `<p><strong>Ch.1 (Scope & Admin):</strong> Permit Required: ${ch1Data.permitRequired ? 'Yes' : 'No'}. Notes: ${ch1Data.notes || 'None'}</p>`;
}
const ch6Data = projectDataContainer.data['types-of-construction'];
if (ch6Data && ch6Data.selectedConstructionType) {
html += `<p><strong>Ch.6 (Construction Types):</strong> Reviewed Construction Type: ${ch6Data.selectedConstructionType}</p>`;
}
const ch7Data = projectDataContainer.data['fire-resistance'];
if (ch7Data && ch7Data.selectedBuildingElement) {
const ratingData = ch7Data.requiredRating || G_CODE_DATA?.tables?.fireResistanceRatingsStructuralElements?.data?.[projectSummaryData.constructionType]?.[ch7Data.selectedBuildingElement];
html += `<p><strong>Ch.7 (Fire Resistance):</strong> Last Element: ${ch7Data.selectedBuildingElement.replace(/_/g,' ')}. Rating: ${ratingData?.rating_hours !== undefined ? (typeof ratingData.rating_hours === 'number' ? ratingData.rating_hours + ' hr' : ratingData.rating_hours) : 'N/A'}. Notes: ${ch7Data.elementNotes || 'None'}</p>`;
}
this.state.summaryHtmlContent = html;
const summaryOutput = document.getElementById('summary-output-section');
if (summaryOutput) summaryOutput.innerHTML = this.state.summaryHtmlContent;
const exportSection = document.getElementById('summary-export-section');
if (exportSection) exportSection.style.display = 'block';
this.app.addOutputMessage('Project summary generated/refreshed.', 'success');
this.app.updateStatus('Summary generated.');
this.saveData();
}
async exportSummaryToPdf() {
const summaryContentDiv = document.getElementById('summary-output-section');
if (!summaryContentDiv || this.state.summaryHtmlContent.includes("Click \"Generate/Refresh Summary\"")) {
this.app.addOutputMessage('Please generate the summary first before exporting.', 'warning');
return;
}
try {
await this.app.ensurePdfLibs();
} catch (libError) {
this.app.addOutputMessage(`Error: PDF libraries failed to load. ${libError.message}`, 'error');
return;
}
this.app.addOutputMessage('Generating Summary PDF...', 'info');
this.app.toggleLoadingOverlay(true);
try {
const clone = summaryContentDiv.cloneNode(true);
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark';
// Explicitly set styles for PDF rendering
const cloneStyle = {
width: '190mm', // Content width within A4 margins
padding: '10px', // Reduced padding for PDF
boxSizing: 'border-box',
visibility: 'visible',
position: 'absolute',
left: '-9999px',
backgroundColor: isDarkMode ? '#2c2c2c' : '#ffffff',
color: isDarkMode ? '#f0f0f0' : '#333',
fontSize: '10pt',
lineHeight: '1.4'
};
Object.assign(clone.style, cloneStyle);
// Style elements within the clone
clone.querySelectorAll('h3, h4').forEach(h => {
h.style.color = isDarkMode ? (getComputedStyle(document.documentElement).getPropertyValue('--primary-color') || '#3498db') : (getComputedStyle(document.documentElement).getPropertyValue('--primary-color') || '#2c3e50');
h.style.marginTop = '1em';
h.style.marginBottom = '0.5em';
});
clone.querySelectorAll('p').forEach(p => {
p.style.color = isDarkMode ? '#f0f0f0' : '#333';
p.style.marginBottom = '0.5em';
});
clone.querySelectorAll('strong').forEach(s => s.style.fontWeight = 'bold');
clone.querySelectorAll('em').forEach(em => em.style.fontStyle = 'italic');
clone.querySelectorAll('hr').forEach(hr => {
hr.style.borderColor = isDarkMode ? '#444' : '#ddd';
hr.style.borderTopWidth = '1px';
hr.style.margin = '1em 0';
});
clone.querySelectorAll('.fa-check-circle').forEach(i => i.style.color = getComputedStyle(document.documentElement).getPropertyValue('--success-color') || '#27ae60');
clone.querySelectorAll('.fa-times-circle').forEach(i => i.style.color = getComputedStyle(document.documentElement).getPropertyValue('--danger-color') || '#e74c3c');
clone.querySelectorAll('[style*="var(--warning-color)"]').forEach(el => el.style.color = getComputedStyle(document.documentElement).getPropertyValue('--warning-color') || '#f39c12');
document.body.appendChild(clone);
const canvas = await window.html2canvas(clone, {
scale: 2,
logging: false,
useCORS: true,
allowTaint: true,
scrollX: -clone.offsetLeft,
scrollY: -clone.offsetTop,
windowWidth: clone.scrollWidth,
windowHeight: clone.scrollHeight,
backgroundColor: cloneStyle.backgroundColor
});
document.body.removeChild(clone);
const { jsPDF } = window.jspdf;
const pdf = new jsPDF('p', 'mm', 'a4');
const imgData = canvas.toDataURL('image/png');
const pdfWidth = pdf.internal.pageSize.getWidth();
const pdfHeight = pdf.internal.pageSize.getHeight();
const margin = 10; // 10mm margin
const contentWidth = pdfWidth - 2 * margin;
const imgOriginalWidth = canvas.width;
const imgOriginalHeight = canvas.height;
const imgDisplayWidth = contentWidth;
const imgDisplayHeight = (imgOriginalHeight * imgDisplayWidth) / imgOriginalWidth;
let currentY = margin;
let remainingImgHeight = imgDisplayHeight;
let sourceYInCanvas = 0; // Y-coordinate in the source canvas image
// Add header text
pdf.setFontSize(16);
pdf.text(`Project Summary: ${this.app.currentProject.name || 'Untitled Project'}`, margin, currentY + 5);
currentY += 10;
pdf.setFontSize(10);
pdf.text(`Location: ${this.app.currentProject.location || 'N/A'}`, margin, currentY + 2);
currentY += 5;
pdf.text(`Code Edition: ${G_CODE_DATA?.codeEdition || "Not Specified"}`, margin, currentY + 2);
currentY += 5;
pdf.text(`Generated: ${new Date().toLocaleString()}`, margin, currentY + 2);
currentY += 10; // Space before content
let pageContentHeightAvailable = pdfHeight - currentY - margin; // Remaining height on first page
while (remainingImgHeight > 0) {
let segmentHeightOnPdf = Math.min(pageContentHeightAvailable, remainingImgHeight);
// Calculate the height of the segment in the original canvas pixels
let segmentHeightInCanvas = (segmentHeightOnPdf / imgDisplayHeight) * imgOriginalHeight;
// Create a temporary canvas to crop the image segment
const tempCanvas = document.createElement('canvas');
tempCanvas.width = imgOriginalWidth;
tempCanvas.height = segmentHeightInCanvas;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.drawImage(canvas, 0, sourceYInCanvas, imgOriginalWidth, segmentHeightInCanvas, 0, 0, imgOriginalWidth, segmentHeightInCanvas);
const segmentImgData = tempCanvas.toDataURL('image/png');
pdf.addImage(segmentImgData, 'PNG', margin, currentY, imgDisplayWidth, segmentHeightOnPdf);
remainingImgHeight -= segmentHeightOnPdf;
sourceYInCanvas += segmentHeightInCanvas;
if (remainingImgHeight > 0) {
pdf.addPage();
currentY = margin; // Reset Y for new page
pageContentHeightAvailable = pdfHeight - 2 * margin; // Full content height for subsequent pages
}
}
const safeName = (this.app.currentProject.name || 'untitled_summary')
.replace(/[^a-zA-Z0-9_.-]/gi, '_')
.toLowerCase();
pdf.save(`${safeName}_project_summary.pdf`);
this.app.addOutputMessage('Summary PDF generated successfully.', 'success');
} catch (err) {
console.error('Error generating summary PDF:', err);
this.app.addOutputMessage(`Error generating summary PDF: ${err.message || err}. See console.`, 'error');
} finally {
this.app.toggleLoadingOverlay(false);
}
}
saveData() {
this.app.updateProjectData(this.chapterId, { summaryHtmlContent: this.state.summaryHtmlContent });
}
loadData(data) {
if (data && data.summaryHtmlContent) {
this.state.summaryHtmlContent = data.summaryHtmlContent;
} else {
this.state.summaryHtmlContent = '<p><em>Click "Generate/Refresh Summary" to see the latest project data and compliance checks.</em></p>';
}
const summaryOutput = document.getElementById('summary-output-section');
if (summaryOutput) summaryOutput.innerHTML = this.state.summaryHtmlContent;
const exportSection = document.getElementById('summary-export-section');
if (exportSection) {
exportSection.style.display = (this.state.summaryHtmlContent && !this.state.summaryHtmlContent.includes("Click \"Generate/Refresh Summary\"")) ? 'block' : 'none';
}
}
}
// --- CHAPTER_DEFINITIONS ---
const CHAPTER_DEFINITIONS = {
'scope-administration': { default: ScopeAdministrationModule, title: 'Chapter 1: Scope & Admin' },
'definitions': { default: DefinitionsModule, title: 'Chapter 2: Definitions' },
'occupancy': { default: OccupancyModule, title: 'Chapter 3: Use & Occupancy' },
'special-uses': { default: SpecialUsesModule, title: 'Chapter 4: Special Uses' },
'general-building': { default: GeneralBuildingModule, title: 'Ch. 5: Heights & Areas' },
'types-of-construction': { default: TypesOfConstructionModule, title: 'Ch. 6: Construction Types' },
'fire-resistance': { default: FireResistanceModule, title: 'Ch. 7: Fire Resistance' },
'interior-finish': { default: InteriorFinishesModule, title: 'Chapter 8: Interior Finishes' },
'fire-protection': { default: FireProtectionModule, title: 'Ch. 9: Fire Protection' },
'means-of-egress': { default: MeansOfEgressModule, title: 'Chapter 10: Means of Egress' },
'accessibility': { default: AccessibilityModule, title: 'Chapter 11: Accessibility' },
'interior-environment': { default: InteriorEnvironmentModule, title: 'Ch. 12/29: Int. Env/Plumbing' },
'energy-efficiency': { default: EnergyEfficiencyModule, title: 'Chapter 13: Energy Efficiency' },
'exterior-walls': { default: ExteriorWallsModule, title: 'Chapter 14: Exterior Walls' },
'roof-assemblies': { default: RoofAssembliesModule, title: 'Chapter 15: Roof Assemblies' },
'summary': { default: SummaryModule, title: 'Project Summary' }
};
// --- Main application controller ---
class BuildingCodeApp {
constructor() {
this.currentProject = {
name: '',
location: '',
data: { projectSummary: {} }
};
this.currentModuleInstance = null;
this.codeEdition = 'IBC (No Data Loaded)';
this.isCodeDataLoaded = false;
this.autosaveInterval = 120000; // 2 minutes
this.autosaveTimerId = null;
this.isNightMode = false;
this.lastSavedProjectState = null; // For comparing if changes occurred for autosave
this.initElements();
this.initEventListeners();
this.loadInitialSettings();
this.updateStatus('Ready. Please load IBC code data.');
this.addOutputMessage('Please load a compatible IBC JSON code data file (e.g., *-D.json) using "Load Code Data".', 'info');
this.loadChapterList();
this.toggleQuickActionButtons(false);
}
loadInitialSettings() {
// Load theme preference
const savedTheme = localStorage.getItem('bcca-theme');
if (savedTheme) {
this.isNightMode = savedTheme === 'dark';
document.documentElement.setAttribute('data-theme', this.isNightMode ? 'dark' : 'light');
this.updateNightModeToggleIcon();
}
// Attempt to load autosaved project
const autosavedProject = localStorage.getItem('bcca-autosave-project');
if (autosavedProject) {
try {
this.currentProject = JSON.parse(autosavedProject);
if (!this.currentProject.data) this.currentProject.data = { projectSummary: {} };
if (!this.currentProject.data.projectSummary) this.currentProject.data.projectSummary = {};
if(this.elements.projectName) this.elements.projectName.value = this.currentProject.name || '';
if(this.elements.projectLocation) this.elements.projectLocation.value = this.currentProject.location || '';
this.updateProjectStatusDot();
this.addOutputMessage('Project restored from autosave.', 'info');
// Defer starting autosave until code data is loaded if not already
} catch (e) {
console.error("Error parsing autosaved project:", e);
localStorage.removeItem('bcca-autosave-project'); // Clear corrupted data
}
}
}
updateNightModeToggleIcon() {
if (this.elements.nightModeToggle) {
this.elements.nightModeToggle.innerHTML = `<i class="fas ${this.isNightMode ? 'fa-sun' : 'fa-moon'}"></i>`;
}
}
toggleNightMode() {
this.isNightMode = !this.isNightMode;
document.documentElement.setAttribute('data-theme', this.isNightMode ? 'dark' : 'light');
localStorage.setItem('bcca-theme', this.isNightMode ? 'dark' : 'light');
this.updateNightModeToggleIcon();
this.addOutputMessage(`Switched to ${this.isNightMode ? 'Night' : 'Light'} Mode.`, 'info');
}
async ensurePdfLibs () {
if (!window.html2canvas) {
try {
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js';
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
} catch (err) {
console.error('Failed to load html2canvas:', err);
throw new Error('Failed to load html2canvas library.');
}
}
if (!window.jspdf || !window.jspdf.jsPDF) {
try {
await new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js';
script.onload = () => {
window.jspdf = window.jspdf || window.jsPDF || {};
if (!window.jspdf.jsPDF && window.jsPDF) {
window.jspdf.jsPDF = window.jsPDF;
}
resolve();
};
script.onerror = reject;
document.head.appendChild(script);
});
} catch (err) {
console.error('Failed to load jsPDF:', err);
throw new Error('Failed to load jsPDF library.');
}
}
if (!window.html2canvas || !window.jspdf || !window.jspdf.jsPDF) {
throw new Error('PDF generation libraries could not be loaded.');
}
}
initElements() {
this.elements = {
chapterTabs: document.getElementById('chapter-tabs'),
moduleContainer: document.getElementById('module-container'),
outputContent: document.getElementById('output-content'),
outputPanel: document.getElementById('output-panel'),
pdfLoadingOverlay: document.getElementById('pdf-loading-overlay'),
statusMessage: document.getElementById('status-message'),
codeReferenceDisplay: document.getElementById('code-reference'),
projectName: document.getElementById('project-name'),
projectLocation: document.getElementById('project-location'),
newProjectBtn: document.getElementById('new-project'),
loadProjectBtn: document.getElementById('load-project'),
saveProjectBtn: document.getElementById('save-project'),
fileInput: document.getElementById('file-input'),
copyReportBtn: document.getElementById('copy-report'),
exportPdfBtn: document.getElementById('export-pdf'),
clearReportBtn: document.getElementById('clear-report'),
loadIbcDataManualWelcomeBtn: document.getElementById('load-ibc-data-manual-welcome'),
loadIbcDataManualHeaderBtn: document.getElementById('load-ibc-data-manual-header'),
manualJsonDataInput: document.getElementById('manual-json-data-input'),
quickOccupancyBtn: document.getElementById('quick-occupancy'),
quickHeightsAreasBtn: document.getElementById('quick-heights-areas'),
quickEgressBtn: document.getElementById('quick-egress'),
quickFireBtn: document.getElementById('quick-fire'),
projectStatusDot: document.getElementById('project-status-dot'),
autosaveStatus: document.getElementById('autosave-status'),
nightModeToggle: document.getElementById('night-mode-toggle')
};
}
initEventListeners() {
this.elements.newProjectBtn?.addEventListener('click', () => this.newProject());
this.elements.loadProjectBtn?.addEventListener('click', () => this.elements.fileInput?.click());
this.elements.saveProjectBtn?.addEventListener('click', () => this.saveProject(false)); // Manual save
this.elements.fileInput?.addEventListener('change', (e) => this.loadProject(e));
this.elements.copyReportBtn?.addEventListener('click', () => this.copyReport());
this.elements.exportPdfBtn?.addEventListener('click', () => this.exportPdf());
this.elements.clearReportBtn?.addEventListener('click', () => this.clearReport());
this.elements.projectName?.addEventListener('input', (e) => {
this.currentProject.name = e.target.value;
this.updateProjectStatusDot();
if (this.isCodeDataLoaded && this.currentProject.name.trim()) {
this.startAutosave(); // Start autosave if project name is entered and code data loaded
} else if (!this.currentProject.name.trim()){
this.stopAutosave();
this.updateAutosaveStatus("Off (No Project Name)");
}
});
this.elements.projectLocation?.addEventListener('input', (e) => {
this.currentProject.location = e.target.value;
this.updateProjectStatusDot();
});
this.elements.loadIbcDataManualWelcomeBtn?.addEventListener('click', () => { this.elements.manualJsonDataInput?.click(); });
this.elements.loadIbcDataManualHeaderBtn?.addEventListener('click', () => { this.elements.manualJsonDataInput?.click(); });
this.elements.manualJsonDataInput?.addEventListener('change', (event) => { this.handleManualJsonFileLoad(event); });
this.elements.quickOccupancyBtn?.addEventListener('click', () => this.loadChapterById('occupancy'));
this.elements.quickHeightsAreasBtn?.addEventListener('click', () => this.loadChapterById('general-building'));
this.elements.quickEgressBtn?.addEventListener('click', () => this.loadChapterById('means-of-egress'));
this.elements.quickFireBtn?.addEventListener('click', () => this.loadChapterById('fire-protection'));
this.elements.nightModeToggle?.addEventListener('click', () => this.toggleNightMode());
}
updateProjectStatusDot() {
if (this.elements.projectStatusDot) {
const hasProjectDetails = this.currentProject.name || this.currentProject.location;
const hasModuleData = this.currentProject.data && Object.keys(this.currentProject.data).some(key => key !== 'projectSummary' && Object.keys(this.currentProject.data[key] || {}).length > 0);
const isActive = this.isCodeDataLoaded && (hasProjectDetails || hasModuleData);
this.elements.projectStatusDot.classList.toggle('active', isActive);
}
}
async exportPdf () {
if (!this.elements.outputContent) return;
if (this.elements.outputContent.children.length === 0) {
this.addOutputMessage('Report panel is empty. Nothing to export.', 'warning');
return;
}
try {
await this.ensurePdfLibs();
} catch (libError) {
console.error('PDF Library Load Error:', libError);
this.addOutputMessage(`Error: PDF libraries failed to load. ${libError.message}`, 'error');
this.toggleLoadingOverlay(false);
return;
}
this.addOutputMessage('Generating PDF…', 'info');
this.toggleLoadingOverlay(true);
try {
const clone = this.elements.outputContent.cloneNode(true);
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark';
const cloneStyle = {
width: '100%', padding: '20px', boxSizing: 'border-box',
visibility: 'visible', position: 'absolute', left: '-9999px',
backgroundColor: isDarkMode ? '#232323' : '#ffffff',
color: isDarkMode ? '#f0f0f0' : '#333'
};
Object.assign(clone.style, cloneStyle);
clone.querySelectorAll('.compliance-message').forEach(cm => {
cm.style.backgroundColor = isDarkMode ? '#232323' : '#f8f9fa'; // Match form-section for light
cm.style.color = isDarkMode ? '#f0f0f0' : '#333';
const smallEl = cm.querySelector('small');
if(smallEl) smallEl.style.color = isDarkMode ? '#aaa' : '#555';
});
document.body.appendChild(clone);
const canvas = await window.html2canvas(clone, {
scale: 2, logging: false, useCORS: true, allowTaint: true,
scrollX: 0, scrollY: 0, windowWidth: clone.scrollWidth, windowHeight: clone.scrollHeight,
backgroundColor: cloneStyle.backgroundColor
});
document.body.removeChild(clone);
const { jsPDF } = window.jspdf;
const pdf = new jsPDF('p', 'mm', 'a4');
const imgData = canvas.toDataURL('image/png');
const props = pdf.getImageProperties(imgData);
const pdfPageWidth = pdf.internal.pageSize.getWidth();
const pdfPageHeight = pdf.internal.pageSize.getHeight();
const margin = 10;
const imgWidthOnPdf = pdfPageWidth - 2 * margin;
const imgHeightOnPdf = (props.height * imgWidthOnPdf) / props.width;
let currentY = margin + 35; // Y position for the first image segment
let remainingImageHeight = imgHeightOnPdf;
let sourceYInCanvas = 0;
pdf.setFontSize(16);
pdf.text(`Code Compliance Report: ${this.currentProject.name || 'Untitled Project'}`, margin, margin + 5);
pdf.setFontSize(10);
pdf.text(`Location: ${this.currentProject.location || 'N/A'}`, margin, margin + 12);
pdf.text(`Code Edition: ${this.codeEdition}`, margin, margin + 19);
pdf.text(`Generated: ${new Date().toLocaleString()}`, margin, margin + 26);
while (remainingImageHeight > 0) {
let pageContentHeightAvailable = pdfPageHeight - currentY - margin;
if (currentY === margin + 35) { // First page has header
// Already accounted for by currentY start
} else { // Subsequent pages
pageContentHeightAvailable = pdfPageHeight - 2 * margin;
currentY = margin; // Reset Y for new page
}
let segmentHeightOnPdf = Math.min(pageContentHeightAvailable, remainingImageHeight);
let segmentHeightInCanvas = (segmentHeightOnPdf / imgHeightOnPdf) * props.height;
const tempCanvas = document.createElement('canvas');
tempCanvas.width = props.width;
tempCanvas.height = segmentHeightInCanvas;
const tempCtx = tempCanvas.getContext('2d');
// Draw the segment from the original large canvas to the temporary canvas
tempCtx.drawImage(canvas, 0, sourceYInCanvas, props.width, segmentHeightInCanvas, 0, 0, props.width, segmentHeightInCanvas);
const segmentImgData = tempCanvas.toDataURL('image/png');
pdf.addImage(segmentImgData, 'PNG', margin, currentY, imgWidthOnPdf, segmentHeightOnPdf);
remainingImageHeight -= segmentHeightOnPdf;
sourceYInCanvas += segmentHeightInCanvas;
currentY += segmentHeightOnPdf; // Prepare for next segment on same page OR reset on new page
if (remainingImageHeight > 0) {
pdf.addPage();
// currentY will be reset at the start of the loop for new pages
}
}
const safeName = (this.currentProject.name || 'untitled_report')
.replace(/[^a-zA-Z0-9_.-]/gi, '_')
.toLowerCase();
pdf.save(`${safeName}_compliance_report.pdf`);
this.addOutputMessage('PDF report generated successfully.', 'success');
} catch (err) {
console.error('Error generating PDF:', err);
this.addOutputMessage(`Error generating PDF: ${err.message || err}. See console for details.`, 'error');
} finally {
this.toggleLoadingOverlay(false);
}
}
toggleLoadingOverlay(show) {
if (this.elements.pdfLoadingOverlay) {
this.elements.pdfLoadingOverlay.classList.toggle('visible', show);
}
}
handleManualJsonFileLoad(event) {
const file = event.target.files[0];
if (!file) return;
if (file.type !== "application/json") {
this.addOutputMessage(`Invalid file type: ${file.type}. Please select a .json file.`, 'error');
event.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = (e_reader) => {
try {
const jsonData = JSON.parse(e_reader.target.result);
if (jsonData && jsonData.tables && jsonData.codeEdition &&
jsonData.tables.allowableBuildingHeightsAreas?.data &&
jsonData.tables.occupancyClassifications?.data &&
jsonData.tables.occupantLoadFactors?.data) {
G_CODE_DATA = jsonData;
this.isCodeDataLoaded = true;
this.codeEdition = G_CODE_DATA?.codeEdition || 'Unknown Code Edition';
if(this.elements.codeReferenceDisplay) this.elements.codeReferenceDisplay.textContent = this.codeEdition;
this.addOutputMessage(`Successfully loaded Code Data: ${this.codeEdition} from ${file.name}`, 'success');
this.updateStatus('Code data loaded. Ready.');
this.loadChapterList();
this.toggleQuickActionButtons(true);
this.updateProjectStatusDot();
if (this.currentProject.name.trim()) { // Start autosave if project name exists
this.startAutosave();
}
const welcomeScreen = this.elements.moduleContainer.querySelector('.welcome-screen');
if(welcomeScreen){
welcomeScreen.innerHTML = `<h2>Code Data Loaded!</h2><p>Select a chapter or use a quick action.</p>`;
const quickActionsDiv = this.elements.moduleContainer.querySelector('.quick-actions');
if (quickActionsDiv) { // Ensure quick actions div is still there or recreate if needed
quickActionsDiv.innerHTML = `
<button id="quick-occupancy"><i class="fas fa-users"></i> Occupancy</button>
<button id="quick-heights-areas"><i class="fas fa-ruler-combined"></i> Heights & Areas</button>
<button id="quick-egress"><i class="fas fa-door-open"></i> Egress Analysis</button>
<button id="quick-fire"><i class="fas fa-fire-extinguisher"></i> Fire Protection</button>
`;
this.elements.quickOccupancyBtn = document.getElementById('quick-occupancy');
this.elements.quickHeightsAreasBtn = document.getElementById('quick-heights-areas');
this.elements.quickEgressBtn = document.getElementById('quick-egress');
this.elements.quickFireBtn = document.getElementById('quick-fire');
this.elements.quickOccupancyBtn?.addEventListener('click', () => this.loadChapterById('occupancy'));
this.elements.quickHeightsAreasBtn?.addEventListener('click', () => this.loadChapterById('general-building'));
this.elements.quickEgressBtn?.addEventListener('click', () => this.loadChapterById('means-of-egress'));
this.elements.quickFireBtn?.addEventListener('click', () => this.loadChapterById('fire-protection'));
this.toggleQuickActionButtons(true);
}
}
// Reload current module if one was active to refresh its content with new code data
if (this.currentModuleInstance && this.currentModuleInstance.chapterId) {
this.loadChapterById(this.currentModuleInstance.chapterId);
} else if (this.currentProject.name) { // If no module was active but a project exists
this.elements.moduleContainer.innerHTML = `<div class="welcome-screen"><h2>Project "${this.currentProject.name}" is loaded.</h2><p>Code data also loaded. Select a chapter.</p></div>`;
}
} else {
throw new Error("JSON file missing key structures (e.g., tables.allowableBuildingHeightsAreas, tables.occupancyClassifications, codeEdition).");
}
} catch (error) {
console.error("Error parsing JSON:", error);
this.addOutputMessage(`Error parsing JSON from ${file.name}: ${error.message}`, 'error');
this.updateStatus('Error loading code data.');
G_CODE_DATA = null;
this.isCodeDataLoaded = false;
if(this.elements.codeReferenceDisplay) this.elements.codeReferenceDisplay.textContent = 'IBC (No Data Loaded)';
this.loadChapterList();
this.toggleQuickActionButtons(false);
this.updateProjectStatusDot();
this.stopAutosave();
}
};
reader.onerror = () => {
this.addOutputMessage(`Error reading file ${file.name}.`, 'error');
this.updateStatus('Error reading code data file.');
};
reader.readAsText(file);
event.target.value = '';
}
toggleQuickActionButtons(enable) {
const buttonsToToggle = [
this.elements.quickOccupancyBtn, this.elements.quickHeightsAreasBtn,
this.elements.quickEgressBtn, this.elements.quickFireBtn,
// Query again in case they were re-created
document.getElementById('quick-occupancy'), document.getElementById('quick-heights-areas'),
document.getElementById('quick-egress'), document.getElementById('quick-fire')
];
buttonsToToggle.forEach(btn => {
if (btn) {
btn.disabled = !enable;
btn.style.opacity = enable ? '1' : '0.6';
btn.style.cursor = enable ? 'pointer' : 'not-allowed';
}
});
}
loadChapterList() {
if (!this.elements.chapterTabs) return;
this.elements.chapterTabs.innerHTML = '';
const activeChapterId = this.currentModuleInstance?.chapterId;
for (const chapterId in CHAPTER_DEFINITIONS) {
const chapterInfo = CHAPTER_DEFINITIONS[chapterId];
const li = document.createElement('li');
const statusIcon = document.createElement('i');
statusIcon.className = 'chapter-status-dot';
const chapterData = this.currentProject.data[chapterId];
if (chapterData && Object.keys(chapterData).length > 0) {
let hasMeaningfulData = false;
if (chapterData.notes && chapterData.notes.trim() !== "") hasMeaningfulData = true;
// Add other specific checks or a generic one
for (const key in chapterData) {
if (key === 'notes' && typeof chapterData[key] === 'string' && chapterData[key].trim() !== '') { hasMeaningfulData = true; break; }
if (key !== 'notes' && chapterData[key] !== undefined && chapterData[key] !== null && chapterData[key] !== '' && chapterData[key] !== 0 && chapterData[key] !== false) {
// Exclude potentially default/empty derived state objects
if (key !== 'state' && key !== 'elementDetails' && key !== 'requiredRatingInfo' && key !== 'requiredFinishIndices' && key !== 'calculatedFixtures' && key !== 'constructionTypeDefinitions' && key !== 'fireResistanceRatings' && key !== 'summaryHtmlContent') {
if (Array.isArray(chapterData[key]) && chapterData[key].length > 0) { hasMeaningfulData = true; break;}
else if (typeof chapterData[key] === 'object' && Object.keys(chapterData[key]).length > 0) { hasMeaningfulData = true; break;}
else if (typeof chapterData[key] !== 'object' && !Array.isArray(chapterData[key])) { hasMeaningfulData = true; break;}
}
}
}
if (hasMeaningfulData) {
statusIcon.classList.add('in-progress');
}
}
li.appendChild(statusIcon);
const titleSpan = document.createElement('span');
titleSpan.textContent = chapterInfo.title || chapterId.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
li.appendChild(titleSpan);
li.dataset.chapterId = chapterId;
const isImplemented = !!chapterInfo.default;
const isEnabled = isImplemented && (this.isCodeDataLoaded || chapterId === 'summary'); // Summary enabled even without code data
if (!isEnabled) {
li.classList.add('disabled');
li.title = !isImplemented ? 'Module not yet implemented' : 'Load IBC Code Data to enable this chapter.';
} else {
li.addEventListener('click', () => this.loadChapterById(chapterId));
}
if (chapterId === activeChapterId) {
li.classList.add('active');
}
this.elements.chapterTabs.appendChild(li);
}
if (!this.isCodeDataLoaded) {
const placeholderLi = document.createElement('li');
const statusIcon = document.createElement('i'); statusIcon.className = 'chapter-status-dot';
placeholderLi.appendChild(statusIcon);
const titleSpan = document.createElement('span'); titleSpan.textContent = 'Load Code Data...';
placeholderLi.appendChild(titleSpan);
placeholderLi.style.fontStyle = 'italic';
placeholderLi.classList.add('disabled');
this.elements.chapterTabs.prepend(placeholderLi);
}
}
triggerGlobalRecalculationDependency(dependencyType) {
if (dependencyType === 'sprinklerStatusChanged') {
this.addOutputMessage('Sprinkler status changed. Dependent calculations (Ch.5, Ch.10) may need re-run.', 'warning');
if (this.currentModuleInstance && typeof this.currentModuleInstance.calculate === 'function') {
if (['general-building', 'means-of-egress'].includes(this.currentModuleInstance.chapterId)) {
this.currentModuleInstance.calculate();
this.addOutputMessage(`Chapter '${CHAPTER_DEFINITIONS[this.currentModuleInstance.chapterId]?.title}' re-calculated due to sprinkler status change.`, 'info');
}
}
// Also update the summary if it's currently displayed
if (this.currentModuleInstance && this.currentModuleInstance.chapterId === 'summary' && typeof this.currentModuleInstance.generateAndDisplaySummary === 'function') {
this.currentModuleInstance.generateAndDisplaySummary();
}
}
}
loadChapterById(chapterId) {
const chapterInfo = CHAPTER_DEFINITIONS[chapterId];
if (!chapterInfo?.default) {
this.addOutputMessage(`Module for '${chapterInfo?.title || chapterId}' is not implemented.`, 'warning');
this.elements.moduleContainer.innerHTML = `<div class="welcome-screen"><h2>Module Not Available</h2><p>The module for '${chapterInfo?.title || chapterId}' is not yet implemented.</p></div>`;
this.currentModuleInstance = null;
Array.from(this.elements.chapterTabs.children).forEach(li => li.classList.remove('active'));
return;
}
if (!this.isCodeDataLoaded && chapterId !== 'summary') { // Allow summary to load without code data
this.addOutputMessage('Cannot load chapter. Please load IBC Code Data first.', 'warning');
this.elements.loadIbcDataManualHeaderBtn?.focus();
return;
}
try {
if (this.currentModuleInstance && typeof this.currentModuleInstance.saveData === 'function') {
this.currentModuleInstance.saveData();
this.performAutosave(true);
}
this.updateStatus(`Loading ${chapterInfo.title}...`);
Array.from(this.elements.chapterTabs.children).forEach(li => {
li.classList.toggle('active', li.dataset.chapterId === chapterId);
});
this.currentModuleInstance = new chapterInfo.default(this);
this.elements.moduleContainer.innerHTML = this.currentModuleInstance.render();
if (typeof this.currentModuleInstance.initEvents === 'function') {
this.currentModuleInstance.initEvents();
}
if (this.currentProject.data && this.currentProject.data[chapterId] && typeof this.currentModuleInstance.loadData === 'function') {
this.currentModuleInstance.loadData(this.currentProject.data[chapterId]);
} else if (typeof this.currentModuleInstance.loadData === 'function') {
// Ensure loadData is called even if no specific data exists for the chapter,
// so it can set up its initial state based on projectSummary or defaults.
this.currentModuleInstance.loadData({});
}
this.updateStatus(`${chapterInfo.title} loaded`);
this.loadChapterList();
} catch (error) {
console.error(`Error loading chapter ${chapterId}:`, error);
this.elements.moduleContainer.innerHTML = `
<div class="welcome-screen"><h2>Error Loading Module</h2><p>${error.message}</p><p>Check console for details.</p></div>`;
this.addOutputMessage(`Error loading chapter ${chapterId}: ${error.message}`, 'error');
this.updateStatus(`Error loading chapter ${chapterId}`);
this.currentModuleInstance = null;
}
}
newProject() {
if (!confirm('Create a new project? Any unsaved changes will be lost.')) return;
this.currentProject = { name: '', location: '', data: { projectSummary: {} } };
if(this.elements.projectName) this.elements.projectName.value = '';
if(this.elements.projectLocation) this.elements.projectLocation.value = '';
this.currentModuleInstance = null;
Array.from(this.elements.chapterTabs.children).forEach(li => li.classList.remove('active'));
this.clearReport();
if (this.isCodeDataLoaded) {
this.elements.moduleContainer.innerHTML = `
<div class="welcome-screen">
<h2>New Project Created</h2>
<p>Select a chapter or use a quick action.</p>
</div>`;
// Autosave will start if project name is entered
} else {
this.elements.moduleContainer.innerHTML = `
<div class="welcome-screen">
<h2>New Project Started</h2>
<p>Please load the IBC code data using the "Load Code Data" button. Then, select a chapter.</p>
<div class="quick-actions">
<button id="load-ibc-data-manual-welcome-new"><i class="fas fa-database"></i> Load IBC Code Data File</button>
</div>
</div>
`;
const loadBtn = document.getElementById('load-ibc-data-manual-welcome-new');
if(loadBtn) loadBtn.addEventListener('click', () => { this.elements.manualJsonDataInput?.click(); });
}
this.updateStatus('New project created');
this.addOutputMessage('New project created. Enter project details and select a chapter.', 'info');
this.updateProjectStatusDot();
this.stopAutosave(); // Stop for new project until name is set
this.updateAutosaveStatus("Off (No Project Name)");
this.lastSavedProjectState = JSON.stringify(this.currentProject);
this.loadChapterList();
}
startAutosave() {
if (!this.isCodeDataLoaded) {
this.updateAutosaveStatus("Off (No Code Data)");
return;
}
if (!this.currentProject.name.trim()) {
this.updateAutosaveStatus("Off (No Project Name)");
return;
}
this.stopAutosave(); // Clear any existing timer
this.autosaveTimerId = setInterval(() => {
this.performAutosave();
}, this.autosaveInterval);
this.updateAutosaveStatus("On");
this.lastSavedProjectState = JSON.stringify(this.currentProject);
console.log("Autosave started.");
}
stopAutosave() {
if (this.autosaveTimerId) {
clearInterval(this.autosaveTimerId);
this.autosaveTimerId = null;
}
// Don't necessarily update status to "Off" here,
// as it might be off for a valid reason (no project name, no code data)
// The `startAutosave` and `newProject` will set appropriate status.
console.log("Autosave timer cleared.");
}
performAutosave(forceSave = false) {
if (!this.isCodeDataLoaded || (!this.currentProject.name.trim() && !forceSave) ) {
return;
}
if (this.currentModuleInstance && typeof this.currentModuleInstance.saveData === 'function') {
this.currentModuleInstance.saveData();
}
const currentProjectState = JSON.stringify(this.currentProject);
if (currentProjectState !== this.lastSavedProjectState || forceSave) {
try {
localStorage.setItem('bcca-autosave-project', currentProjectState);
this.lastSavedProjectState = currentProjectState;
const time = new Date().toLocaleTimeString();
this.updateAutosaveStatus(`Saved ${time}`);
console.log(`Project autosaved to localStorage at ${time}`);
} catch (e) {
console.error("Autosave to localStorage failed:", e);
this.addOutputMessage("Autosave failed. LocalStorage might be full.", "error");
this.stopAutosave();
this.updateAutosaveStatus("Error!");
}
}
}
updateAutosaveStatus(status) {
if (this.elements.autosaveStatus) {
this.elements.autosaveStatus.textContent = `Autosave: ${status}`;
}
}
saveProject(isAutosave = false) {
if (!this.currentProject.name.trim()) {
alert('Please enter a project name before saving.');
this.elements.projectName?.focus();
return;
}
if (this.currentModuleInstance && typeof this.currentModuleInstance.saveData === 'function') {
this.currentModuleInstance.saveData();
}
try {
const projectDataString = JSON.stringify(this.currentProject, null, 2);
localStorage.setItem('bcca-autosave-project', projectDataString);
this.lastSavedProjectState = projectDataString;
const time = new Date().toLocaleTimeString();
this.updateAutosaveStatus(`Saved ${time}`);
console.log(`Project state saved to localStorage at ${time} (manual save context)`);
if (!isAutosave) {
const blob = new Blob([projectDataString], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const safeProjectName = (this.currentProject.name || 'untitled_project').replace(/[^a-zA-Z0-9_.-]/gi, '_').toLowerCase();
a.download = `${safeProjectName}_bcc_project.json`;
a.href = url;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
this.updateStatus('Project downloaded');
this.addOutputMessage('Project downloaded successfully and saved locally.', 'success');
}
} catch (e) {
console.error("Save to localStorage failed:", e);
this.addOutputMessage("Saving to local storage failed. It might be full.", "error");
if (!isAutosave) {
this.updateStatus('Error saving project');
} else {
this.stopAutosave();
this.updateAutosaveStatus("Error!");
}
}
}
loadProject(event) {
const file = event.target.files[0];
if (!file) return;
this.stopAutosave();
const reader = new FileReader();
reader.onload = (e) => {
try {
const loadedProject = JSON.parse(e.target.result);
if (loadedProject && typeof loadedProject.name !== 'undefined' && typeof loadedProject.data !== 'undefined') {
this.currentProject = loadedProject;
if (!this.currentProject.data.projectSummary) {
this.currentProject.data.projectSummary = {};
}
if(this.elements.projectName) this.elements.projectName.value = this.currentProject.name || '';
if(this.elements.projectLocation) this.elements.projectLocation.value = this.currentProject.location || '';
Array.from(this.elements.chapterTabs.children).forEach(li => li.classList.remove('active'));
this.currentModuleInstance = null;
if (this.isCodeDataLoaded) {
this.elements.moduleContainer.innerHTML = `<div class="welcome-screen"><h2>Project "${this.currentProject.name || 'Untitled'}" Loaded</h2><p>Select a chapter.</p></div>`;
if (this.currentProject.name.trim()) this.startAutosave();
} else {
this.elements.moduleContainer.innerHTML = `
<div class="welcome-screen">
<h2>Project "${this.currentProject.name || 'Untitled'}" Loaded</h2>
<p style="color: var(--warning-color); font-weight: bold;">Load IBC code data to continue.</p>
<div class="quick-actions">
<button id="load-ibc-data-manual-project-load"><i class="fas fa-database"></i> Load IBC Code Data File</button>
</div>
</div>`;
const loadBtn = document.getElementById('load-ibc-data-manual-project-load');
if(loadBtn) loadBtn.addEventListener('click', () => { this.elements.manualJsonDataInput?.click(); });
}
this.updateStatus(`Project "${this.currentProject.name || 'Untitled'}" loaded`);
this.addOutputMessage(`Project "${this.currentProject.name || 'Untitled'}" loaded`, 'success');
this.lastSavedProjectState = JSON.stringify(this.currentProject);
this.loadChapterList();
this.updateProjectStatusDot();
} else { throw new Error("Invalid project file format."); }
} catch (error) {
console.error('Error loading project:', error);
this.addOutputMessage(`Error loading project file: ${error.message}`, 'error');
this.updateStatus('Error loading project');
}
};
reader.readAsText(file);
event.target.value = '';
}
addOutputMessage(message, type = 'info') {
if (!this.elements.outputContent) return;
const messageDiv = document.createElement('div');
messageDiv.className = `compliance-message ${type}`;
const iconClass = type === 'success' ? 'fa-check-circle' : type === 'warning' ? 'fa-exclamation-triangle' : type === 'error' ? 'fa-times-circle' : 'fa-info-circle';
const safeMessage = message.toString().replace(/</g, "<").replace(/>/g, ">");
const iconElement = document.createElement('i');
iconElement.className = `fas ${iconClass}`;
const textContainer = document.createElement('div');
const p = document.createElement('p');
p.innerHTML = safeMessage;
const small = document.createElement('small');
small.textContent = new Date().toLocaleString();
textContainer.appendChild(p);
textContainer.appendChild(small);
messageDiv.appendChild(iconElement);
messageDiv.appendChild(textContainer);
this.elements.outputContent.appendChild(messageDiv);
this.elements.outputContent.scrollTop = this.elements.outputContent.scrollHeight;
}
copyReport() {
if(!this.elements.outputContent) return;
const reportText = Array.from(this.elements.outputContent.children)
.map(el => {
const pElement = el.querySelector('p');
const smallElement = el.querySelector('small');
let text = pElement ? pElement.innerText : el.innerText;
if(smallElement) text += ` (${smallElement.innerText})`;
return text;
})
.join('\n---\n');
if (reportText.trim() === "") {
this.addOutputMessage('Report is empty.', 'warning');
return;
}
navigator.clipboard.writeText(reportText)
.then(() => this.addOutputMessage('Report copied to clipboard.', 'success'))
.catch(err => {
console.error('Failed to copy report: ', err);
this.addOutputMessage('Failed to copy report. See console.', 'error');
});
}
clearReport() {
if(this.elements.outputContent) this.elements.outputContent.innerHTML = '';
this.addOutputMessage('Report cleared.', 'info');
}
updateStatus(message) {
if(this.elements.statusMessage) this.elements.statusMessage.textContent = message;
}
getProjectData() {
if (!this.currentProject.data) {
this.currentProject.data = { projectSummary: {} };
}
if (!this.currentProject.data.projectSummary) {
this.currentProject.data.projectSummary = {};
}
return this.currentProject;
}
updateProjectData(key, data) {
if (!this.currentProject.data) {
this.currentProject.data = { projectSummary: {} };
}
if (key === 'projectSummary' && !this.currentProject.data.projectSummary) {
this.currentProject.data.projectSummary = {};
}
this.currentProject.data[key] = data;
this.updateProjectStatusDot();
this.loadChapterList();
}
}
document.addEventListener('DOMContentLoaded', () => {
const app = new BuildingCodeApp();
window.buildingCodeApp = app; // Make app accessible for debugging if needed
});
</script>
</body>
</html>