FHS Network Topology

Click any device or office to see its details in a popup. Edit cost, owner, model, and notes in the Bill of Materials table below. Turn on Edit layout to drag/resize nodes and containers and re-route connections.

0 annotated
Total cost $0 across 0 priced items
Internet
Main Office
Ashley Office
S&S Office
Household
Management overlay
Primary network path
Management (out-of-band)

Bill of Materials 0 items

Device Type Office / Zone Owner Model / Specs Cost Notes
Subtotal of visible priced items $0
{}; [...NODES, ...CONTAINERS].forEach(item => { layout[item.id] = { x: item.x, y: item.y, w: item.w, h: item.h }; }); return layout; } function applyLayout(layout) { let count = 0; [...NODES, ...CONTAINERS].forEach(item => { const l = layout[item.id]; if (l && typeof l.x === 'number') { item.x = l.x; item.y = l.y; item.w = l.w; item.h = l.h; count++; } }); return count; } // Detect import file format and split into annotations / layout pieces. // Supports: // v2 combined: { version: 2, annotations: {...}, layout: {...} } // legacy annotations-only: flat object whose values look like { cost, owner, model, notes } // legacy layout-only: flat object whose values look like { x, y, w, h } function detectImport(data) { if (!data || typeof data !== 'object') throw new Error('Not a JSON object'); if (data.annotations || data.layout || data.connections || data.nodeAssignments || data.version) { return { annotations: data.annotations || null, layout: data.layout || null, connections: Array.isArray(data.connections) ? data.connections : null, nodeAssignments: data.nodeAssignments || null, format: 'combined v' + (data.version || '?'), }; } // Sniff legacy format from first entry const keys = Object.keys(data); if (keys.length === 0) return { annotations: {}, layout: null, connections: null, nodeAssignments: null, format: 'empty' }; const v = data[keys[0]]; if (v && (typeof v.x === 'number' || typeof v.w === 'number')) { return { annotations: null, layout: data, connections: null, nodeAssignments: null, format: 'legacy-layout' }; } return { annotations: data, layout: null, connections: null, nodeAssignments: null, format: 'legacy-annotations' }; } document.getElementById('exportCsvBtn').addEventListener('click', () => { const headers = ['id', 'name', 'type', 'zone', 'cost', 'owner', 'model', 'notes']; const rows = [headers.join(',')]; const items = [...NODES, ...CONTAINERS]; items.forEach(n => { const a = annotations[n.id] || {}; const type = CONTAINERS.find(c => c.id === n.id) ? 'Site' : n.type; const row = [n.id, n.name, type, n.zone, a.cost || '', a.owner || '', a.model || '', (a.notes || '').replace(/[\r\n]+/g, ' ')] .map(v => `"${String(v).replace(/"/g, '""')}"`).join(','); rows.push(row); }); download('fhs-network-annotations.csv', rows.join('\r\n'), 'text/csv'); }); document.getElementById('exportJsonBtn').addEventListener('click', () => { const assignments = {}; NODES.forEach(n => { assignments[n.id] = { container: n.container, zone: n.zone }; }); const payload = { version: 3, exportedAt: new Date().toISOString(), annotations: annotations, layout: currentLayout(), connections: CONNECTIONS, nodeAssignments: assignments, }; download('fhs-network.json', JSON.stringify(payload, null, 2), 'application/json'); }); document.getElementById('importBtn').addEventListener('click', () => { document.getElementById('importFile').click(); }); document.getElementById('importFile').addEventListener('change', e => { const file = e.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = ev => { try { const parsed = JSON.parse(ev.target.result); const detected = detectImport(parsed); const parts = []; if (detected.annotations) parts.push(`${Object.keys(detected.annotations).length} annotation entries`); if (detected.layout) parts.push(`${Object.keys(detected.layout).length} layout entries`); if (detected.connections) parts.push(`${detected.connections.length} connections`); if (detected.nodeAssignments) parts.push(`${Object.keys(detected.nodeAssignments).length} device→office assignments`); if (parts.length === 0) { alert('File contained nothing importable.'); return; } if (!confirm(`Import this file?\n\nFormat: ${detected.format}\nContents: ${parts.join(' + ')}\n\nMatching state will be replaced.`)) return; if (detected.annotations) { annotations = detected.annotations; localStorage.setItem(ANN_KEY, JSON.stringify(annotations)); } if (detected.layout) { applyLayout(detected.layout); saveLayout(); } if (detected.connections) { CONNECTIONS.length = 0; detected.connections.forEach(c => CONNECTIONS.push(c)); saveConnections(); } if (detected.nodeAssignments) { NODES.forEach(n => { const a = detected.nodeAssignments[n.id]; if (a) { n.container = a.container; n.zone = a.zone; } }); saveNodeAssignments(); } selectedConnIdx = null; renderDiagram(); updateSummary(); renderBoM(); if (editMode) refreshBaseline(); hidePopup(); } catch (err) { alert('Invalid JSON file: ' + err.message); } }; reader.readAsText(file); e.target.value = ''; }); document.getElementById('clearBtn').addEventListener('click', () => { if (confirm('Clear ALL annotations across the entire diagram? This cannot be undone (export first if you want a backup).')) { annotations = {}; saveAnnotations(); renderBoM(); if (selectedId) { const a = findSvgAnchor(selectedId); const item = findById(selectedId); if (item && a) showItemPopup(item, a); } } }); function download(name, content, mime) { const blob = new Blob([content], { type: mime }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } // BoM controls document.getElementById('bomFilter').addEventListener('input', renderBoM); document.getElementById('bomZoneFilter').addEventListener('change', renderBoM); document.getElementById('bomIncludeSites').addEventListener('change', renderBoM); document.getElementById('bomOnlyPriced').addEventListener('change', renderBoM); document.querySelectorAll('.bom-table th.sortable').forEach(th => { th.addEventListener('click', () => { const k = th.dataset.sort; if (bomSort.key === k) { bomSort.dir = bomSort.dir === 'asc' ? 'desc' : 'asc'; } else { bomSort.key = k; bomSort.dir = (k === 'cost') ? 'desc' : 'asc'; } renderBoM(); }); }); // Global popup dismissal — ESC and click-outside document.addEventListener('keydown', e => { if (e.key === 'Escape' && popupAnchor) hidePopup(); }); document.addEventListener('mousedown', e => { if (!popupAnchor) return; const popup = document.getElementById('objectPopup'); if (popup.contains(e.target)) return; // Clicks on diagram objects / BoM rows are handled by their own selection logic (which will re-open or move the popup) if (e.target.closest('.node, .container-box[data-id], [data-conn-idx], .endpoint-handle, .resize-handle, tr.bom-row')) return; hidePopup(); }); window.addEventListener('scroll', () => { if (popupAnchor) positionPopup(); }, true); window.addEventListener('resize', () => { if (popupAnchor) positionPopup(); }); renderDiagram(); setupSvgHandlers(); updateSummary(); renderBoM();