|
<!DOCTYPE html> |
|
<html lang="en"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>dbt City: SimCity-style Manifest Viewer</title> |
|
<style> |
|
* { margin: 0; padding: 0; box-sizing: border-box; } |
|
body { background: #1a1a2e; overflow: hidden; font-family: 'Segoe UI', system-ui, sans-serif; } |
|
canvas { display: block; cursor: grab; } |
|
canvas:active { cursor: grabbing; } |
|
|
|
#tooltip { |
|
position: fixed; display: none; pointer-events: none; |
|
background: #383838; color: #fff; padding: 10px 14px; |
|
border-radius: 6px; font-size: 13px; max-width: 320px; |
|
box-shadow: 0 4px 20px rgba(0,0,0,0.5); z-index: 100; |
|
border-left: 3px solid #6FC2FF; |
|
} |
|
#tooltip .tt-name { font-weight: 700; font-size: 14px; margin-bottom: 4px; } |
|
#tooltip .tt-badge { |
|
display: inline-block; padding: 1px 6px; border-radius: 3px; |
|
font-size: 11px; font-weight: 600; margin-left: 6px; vertical-align: middle; |
|
} |
|
#tooltip .tt-schema { color: #aaa; font-size: 12px; margin-bottom: 4px; } |
|
#tooltip .tt-desc { color: #ccc; font-size: 12px; margin-bottom: 6px; line-height: 1.4; } |
|
#tooltip .tt-stats { font-size: 12px; color: #6FC2FF; } |
|
|
|
#legend { |
|
position: fixed; bottom: 20px; left: 20px; background: rgba(56,56,56,0.92); |
|
padding: 14px 18px; border-radius: 8px; color: #fff; font-size: 12px; |
|
z-index: 50; backdrop-filter: blur(8px); |
|
} |
|
#legend h3 { font-size: 13px; margin-bottom: 8px; color: #FFDE02; } |
|
kbd { |
|
background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); |
|
border-radius: 3px; padding: 1px 4px; font-size: 10px; font-family: inherit; |
|
margin: 0 1px; |
|
} |
|
.legend-item { display: flex; align-items: center; margin-bottom: 5px; } |
|
.legend-swatch { width: 18px; height: 12px; margin-right: 8px; border-radius: 2px; } |
|
|
|
#title-bar { |
|
position: fixed; top: 0; left: 0; right: 0; height: 48px; |
|
background: rgba(26,26,46,0.9); backdrop-filter: blur(8px); |
|
display: flex; align-items: center; padding: 0 20px; z-index: 50; |
|
border-bottom: 1px solid rgba(111,194,255,0.15); |
|
} |
|
#title-bar h1 { font-size: 18px; color: #FFDE02; font-weight: 700; } |
|
#title-bar .subtitle { color: #6FC2FF; font-size: 13px; margin-left: 12px; } |
|
|
|
#search-box { |
|
margin-left: auto; background: rgba(255,255,255,0.08); |
|
border: 1px solid rgba(255,255,255,0.15); border-radius: 5px; |
|
color: #fff; padding: 6px 12px; font-size: 13px; width: 220px; |
|
outline: none; |
|
} |
|
#search-box:focus { border-color: #6FC2FF; } |
|
#search-box::placeholder { color: rgba(255,255,255,0.35); } |
|
|
|
#loading { |
|
position: fixed; inset: 0; background: #1a1a2e; display: flex; |
|
align-items: center; justify-content: center; flex-direction: column; |
|
z-index: 200; color: #fff; |
|
} |
|
#loading h2 { color: #FFDE02; margin-bottom: 10px; } |
|
#loading p { color: #6FC2FF; font-size: 14px; } |
|
|
|
#info-panel { |
|
position: fixed; top: 60px; right: 20px; width: 280px; |
|
background: rgba(56,56,56,0.92); padding: 16px; border-radius: 8px; |
|
color: #fff; font-size: 12px; z-index: 50; display: none; |
|
backdrop-filter: blur(8px); border: 1px solid rgba(111,194,255,0.2); |
|
} |
|
#info-panel h3 { color: #FFDE02; margin-bottom: 8px; } |
|
#info-panel .close-btn { |
|
position: absolute; top: 8px; right: 12px; cursor: pointer; |
|
color: #aaa; font-size: 16px; |
|
} |
|
#info-panel .close-btn:hover { color: #fff; } |
|
.lineage-list { max-height: 150px; overflow-y: auto; margin-top: 6px; } |
|
.lineage-list div { padding: 2px 0; color: #ccc; } |
|
.lineage-list div:hover { color: #6FC2FF; cursor: pointer; } |
|
|
|
#texture-panel { |
|
position: fixed; bottom: 20px; right: 20px; background: rgba(56,56,56,0.92); |
|
padding: 14px 18px; border-radius: 8px; color: #fff; font-size: 12px; |
|
z-index: 50; backdrop-filter: blur(8px); min-width: 200px; |
|
border: 1px solid rgba(111,194,255,0.15); |
|
} |
|
#texture-panel h3 { font-size: 13px; margin-bottom: 8px; color: #FFDE02; } |
|
#texture-panel label { display: flex; align-items: center; gap: 8px; cursor: pointer; margin-bottom: 6px; } |
|
#texture-panel .sprite-status { color: #aaa; font-size: 11px; margin-top: 4px; } |
|
#texture-panel .sprite-load-btn { |
|
background: rgba(111,194,255,0.2); border: 1px solid rgba(111,194,255,0.3); |
|
color: #6FC2FF; padding: 4px 10px; border-radius: 4px; cursor: pointer; |
|
font-size: 11px; margin-top: 6px; display: inline-block; |
|
} |
|
#texture-panel .sprite-load-btn:hover { background: rgba(111,194,255,0.3); } |
|
#texture-panel input[type="file"] { display: none; } |
|
.sprite-preview-grid { |
|
display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; margin-top: 8px; |
|
} |
|
.sprite-preview-grid .sp-item { |
|
text-align: center; font-size: 10px; color: #aaa; |
|
} |
|
.sprite-preview-grid .sp-item img { |
|
width: 100%; height: 32px; object-fit: contain; background: rgba(0,0,0,0.3); |
|
border-radius: 3px; border: 1px solid rgba(255,255,255,0.1); |
|
} |
|
.sprite-preview-grid .sp-item.loaded img { border-color: #16AA98; } |
|
.sprite-preview-grid .sp-item.missing img { opacity: 0.3; } |
|
</style> |
|
</head> |
|
<body> |
|
|
|
<div id="title-bar"> |
|
<h1>dbt City</h1> |
|
<span class="subtitle">SimCity-style manifest explorer</span> |
|
<span id="stats" class="subtitle" style="margin-left:20px; color:#aaa; font-size:12px;"></span> |
|
<input id="search-box" type="text" placeholder="Search models..."> |
|
</div> |
|
|
|
<canvas id="city"></canvas> |
|
|
|
<div id="tooltip"></div> |
|
|
|
<div id="legend"> |
|
<h3>Building Types</h3> |
|
<div class="legend-item"><div class="legend-swatch" style="background:#6FC2FF"></div>Model</div> |
|
<div class="legend-item"><div class="legend-swatch" style="background:#383838; border:1px solid #888"></div>Source (Factory)</div> |
|
<div class="legend-item"><div class="legend-swatch" style="background:#16AA98"></div>Seed (Park)</div> |
|
<div class="legend-item"><div class="legend-swatch" style="background:#FF7169"></div>Snapshot (Warehouse)</div> |
|
<div style="margin-top:8px; border-top:1px solid rgba(255,255,255,0.1); padding-top:8px;"> |
|
<div class="legend-item"><div class="legend-swatch" style="background:rgba(255,222,2,0.6)"></div>Lineage path</div> |
|
</div> |
|
<div style="margin-top:8px; border-top:1px solid rgba(255,255,255,0.1); padding-top:8px; color:#aaa; font-size:11px; line-height:1.6;"> |
|
<div><kbd>W</kbd><kbd>A</kbd><kbd>S</kbd><kbd>D</kbd> / arrows: pan</div> |
|
<div><kbd>Q</kbd><kbd>E</kbd> / <kbd>+</kbd><kbd>-</kbd>: zoom</div> |
|
<div><kbd>Space</kbd>: reset view</div> |
|
<div><kbd>Esc</kbd>: clear selection</div> |
|
<div>Click building: show lineage</div> |
|
</div> |
|
</div> |
|
|
|
<div id="info-panel"> |
|
<span class="close-btn" onclick="clearSelection()">×</span> |
|
<div id="info-content"></div> |
|
</div> |
|
|
|
<div id="texture-panel"> |
|
<h3>Textures</h3> |
|
<label> |
|
<input type="checkbox" id="texture-toggle"> Enable sprite textures |
|
</label> |
|
<div class="sprite-status" id="sprite-status">No sprites loaded</div> |
|
<div style="margin-top:8px;"> |
|
<span class="sprite-load-btn" id="load-manifest-btn" style="display:none;">Load from manifest</span> |
|
<span class="sprite-load-btn" id="load-sprites-btn" style="display:none;">Upload sprites</span> |
|
<input type="file" id="sprite-file-input" accept="image/png,image/webp,image/svg+xml" multiple style="display:none;"> |
|
</div> |
|
<div class="sprite-preview-grid" id="sprite-previews"></div> |
|
</div> |
|
|
|
<div id="loading"> |
|
<h2>Building your data city...</h2> |
|
<p id="load-status">Laying foundations...</p> |
|
</div> |
|
|
|
<script src="https://d3js.org/d3.v7.min.js"></script> |
|
<script> |
|
// === EMBEDDED DATA (will be injected) === |
|
let DATA = null; |
|
const EMBEDDED_DATA = {"nodes":[{"id":"source.jaffle_shop.raw.raw_customers","name":"raw_customers","type":"source","schema":"raw","sourceName":"raw","description":"Raw customer data","childCount":1,"parentCount":0},{"id":"source.jaffle_shop.raw.raw_orders","name":"raw_orders","type":"source","schema":"raw","sourceName":"raw","description":"Raw order data","childCount":1,"parentCount":0},{"id":"source.jaffle_shop.raw.raw_payments","name":"raw_payments","type":"source","schema":"raw","sourceName":"raw","description":"Raw payment data","childCount":1,"parentCount":0},{"id":"model.jaffle_shop.stg_customers","name":"stg_customers","type":"model","schema":"staging","description":"Cleaned customer data","childCount":2,"parentCount":1},{"id":"model.jaffle_shop.stg_orders","name":"stg_orders","type":"model","schema":"staging","description":"Cleaned order data","childCount":2,"parentCount":1},{"id":"model.jaffle_shop.stg_payments","name":"stg_payments","type":"model","schema":"staging","description":"Cleaned payment data","childCount":2,"parentCount":1},{"id":"model.jaffle_shop.customers","name":"customers","type":"model","schema":"marts","description":"Customer table with lifetime stats","childCount":0,"parentCount":3},{"id":"model.jaffle_shop.orders","name":"orders","type":"model","schema":"marts","description":"Order table with payment amounts","childCount":0,"parentCount":3}],"edges":[["source.jaffle_shop.raw.raw_customers","model.jaffle_shop.stg_customers"],["source.jaffle_shop.raw.raw_orders","model.jaffle_shop.stg_orders"],["source.jaffle_shop.raw.raw_payments","model.jaffle_shop.stg_payments"],["model.jaffle_shop.stg_customers","model.jaffle_shop.customers"],["model.jaffle_shop.stg_orders","model.jaffle_shop.customers"],["model.jaffle_shop.stg_payments","model.jaffle_shop.customers"],["model.jaffle_shop.stg_customers","model.jaffle_shop.orders"],["model.jaffle_shop.stg_orders","model.jaffle_shop.orders"],["model.jaffle_shop.stg_payments","model.jaffle_shop.orders"]]}; |
|
; |
|
|
|
// === INLINE MANIFEST (for self-contained gist) === |
|
const INLINE_MANIFEST = {"description": "Sprite manifest for dbt City texture mode.", "spriteSheet": null, "sprites": {"building-small": {"file": "building-small.png", "description": "Isometric office building, 2-3 floors", "width": 64, "height": 96, "anchorX": 32, "anchorY": 80}, "building-medium": {"file": "building-medium.png", "description": "Isometric office building, 5-6 floors", "width": 64, "height": 128, "anchorX": 32, "anchorY": 96}, "building-large": {"file": "building-large.svg", "description": "Isometric tall skyscraper, 10+ floors", "width": 80, "height": 160, "anchorX": 40, "anchorY": 112}, "factory": {"file": "factory.png", "description": "Isometric industrial factory", "width": 80, "height": 112, "anchorX": 40, "anchorY": 88}, "park": {"file": "park.svg", "description": "Isometric green park with tree(s)", "width": 64, "height": 80, "anchorX": 32, "anchorY": 64}, "warehouse": {"file": "warehouse.svg", "description": "Isometric warehouse/storage building", "width": 80, "height": 96, "anchorX": 40, "anchorY": 72}, "grass-tile": {"file": "grass-tile.png", "description": "Isometric grass/terrain diamond tile", "width": 128, "height": 64, "anchorX": 64, "anchorY": 32}, "road-2lane": {"file": "road-2lane.png", "description": "Isometric 2-lane road diamond tile", "width": 128, "height": 64, "anchorX": 64, "anchorY": 32}, "road-4lane": {"file": "road-4lane.svg", "description": "Isometric 4-lane highway diamond tile", "width": 128, "height": 64, "anchorX": 64, "anchorY": 32}}, "typeMapping": {"model": {"small": "building-small", "medium": "building-medium", "large": "building-large", "sizeThresholds": [5, 12]}, "source": {"default": "factory"}, "seed": {"default": "park"}, "snapshot": {"default": "warehouse"}}, "roadMapping": {"default": "road-2lane", "highway": "road-4lane", "highwayThreshold": 4}, "ground": "grass-tile"}; |
|
|
|
// === INLINE SPRITES (base64 data URIs for self-contained gist) === |
|
const INLINE_SPRITES = { |
|
'building-large.svg': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4MCIgaGVpZ2h0PSIxNjAiPgo8cGF0aCBkPSJNNTAuNCwyOS4wIEw1My4wLDQyLjUgTDI5LjYsNDEuMCBMMjcuMCwyNy41IFoiIGZpbGw9IiM3QkI4RTgiLz4KPHBhdGggZD0iTTUzLjAsNDIuNSBMMjkuNiw0MS4wIEwyOS42LDEyNi4wIEw1My4wLDEyNy41IFoiIGZpbGw9IiM0QTlBRDgiLz4KPHBhdGggZD0iTTI5LjYsNDEuMCBMMjcuMCwyNy41IEwyNy4wLDExMi41IEwyOS42LDEyNi4wIFoiIGZpbGw9IiMyQTdBQkYiLz4KPHJlY3QgeD0iNDUuNiIgeT0iMTE5LjEiIHdpZHRoPSIzIiBoZWlnaHQ9IjQiIGZpbGw9IiNGRkRFMDIiIG9wYWNpdHk9IjAuNyIvPgo8cmVjdCB4PSIzNi4zIiB5PSIxMTguNSIgd2lkdGg9IjMiIGhlaWdodD0iNCIgZmlsbD0iI0ZGREUwMiIgb3BhY2l0eT0iMC43Ii8+CjxyZWN0IHg9IjQ1LjYiIHk9IjEwNy4xIiB3aWR0aD0iMyIgaGVpZ2h0PSI0IiBmaWxsPSIjRkZERTAyIiBvcGFjaXR5PSIwLjciLz4KPHJlY3QgeD0iMzYuMyIgeT0iMTA2LjUiIHdpZHRoPSIzIiBoZWlnaHQ9IjQiIGZpbGw9IiNGRkRFMDIiIG9wYWNpdHk9IjAuNyIvPgo8cmVjdCB4PSI0NS42IiB5PSI5NS4xIiB3aWR0aD0iMyIgaGVpZ2h0PSI0IiBmaWxsPSIjRkZERTAyIiBvcGFjaXR5PSIwLjciLz4KPHJlY3QgeD0iMzYuMyIgeT0iOTQuNSIgd2lkdGg9IjMiIGhlaWdodD0iNCIgZmlsbD0iI0ZGREUwMiIgb3BhY2l0eT0iMC43Ii8+CjxyZWN0IHg9IjQ1LjYiIHk9IjgzLjEiIHdpZHRoPSIzIiBoZWlnaHQ9IjQiIGZpbGw9IiNGRkRFMDIiIG9wYWNpdHk9IjAuNyIvPgo8cmVjdCB4PSIzNi4zIiB5PSI4Mi41IiB3aWR0aD0iMyIgaGVpZ2h0PSI0IiBmaWxsPSIjRkZERTAyIiBvcGFjaXR5PSIwLjciLz4KPHJlY3QgeD0iNDUuNiIgeT0iNzEuMSIgd2lkdGg9IjMiIGhlaWdodD0iNCIgZmlsbD0iI0ZGREUwMiIgb3BhY2l0eT0iMC43Ii8+CjxyZWN0IHg9IjM2LjMiIHk9IjcwLjUiIHdpZHRoPSIzIiBoZWlnaHQ9IjQiIGZpbGw9IiNGRkRFMDIiIG9wYWNpdHk9IjAuNyIvPgo8cmVjdCB4PSI0NS42IiB5PSI1OS4xIiB3aWR0aD0iMyIgaGVpZ2h0PSI0IiBmaWxsPSIjRkZERTAyIiBvcGFjaXR5PSIwLjciLz4KPHJlY3QgeD0iMzYuMyIgeT0iNTguNSIgd2lkdGg9IjMiIGhlaWdodD0iNCIgZmlsbD0iI0ZGREUwMiIgb3BhY2l0eT0iMC43Ii8+CjxyZWN0IHg9IjQ1LjYiIHk9IjQ3LjEiIHdpZHRoPSIzIiBoZWlnaHQ9IjQiIGZpbGw9IiNGRkRFMDIiIG9wYWNpdHk9IjAuNyIvPgo8cmVjdCB4PSIzNi4zIiB5PSI0Ni41IiB3aWR0aD0iMyIgaGVpZ2h0PSI0IiBmaWxsPSIjRkZERTAyIiBvcGFjaXR5PSIwLjciLz4KCjwvc3ZnPg==', |
|
'building-medium.svg': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NCIgaGVpZ2h0PSIxMjgiPgo8cGF0aCBkPSJNNDAuMiwzNi4yIEw0Mi40LDQ3LjAgTDIzLjgsNDUuOCBMMjEuNiwzNS4wIFoiIGZpbGw9IiM5REQ4RkYiLz4KPHBhdGggZD0iTTQyLjQsNDcuMCBMMjMuOCw0NS44IEwyMy44LDEwMC44IEw0Mi40LDEwMi4wIFoiIGZpbGw9IiM2RkMyRkYiLz4KPHBhdGggZD0iTTIzLjgsNDUuOCBMMjEuNiwzNS4wIEwyMS42LDkwLjAgTDIzLjgsMTAwLjggWiIgZmlsbD0iIzRBOUFEOCIvPgo8cmVjdCB4PSIzNi4yIiB5PSI5My43IiB3aWR0aD0iMyIgaGVpZ2h0PSI0IiBmaWxsPSIjRkZERTAyIiBvcGFjaXR5PSIwLjciLz4KPHJlY3QgeD0iMjguOCIgeT0iOTMuMiIgd2lkdGg9IjMiIGhlaWdodD0iNCIgZmlsbD0iI0ZGREUwMiIgb3BhY2l0eT0iMC43Ii8+CjxyZWN0IHg9IjM2LjIiIHk9IjgxLjciIHdpZHRoPSIzIiBoZWlnaHQ9IjQiIGZpbGw9IiNGRkRFMDIiIG9wYWNpdHk9IjAuNyIvPgo8cmVjdCB4PSIyOC44IiB5PSI4MS4yIiB3aWR0aD0iMyIgaGVpZ2h0PSI0IiBmaWxsPSIjRkZERTAyIiBvcGFjaXR5PSIwLjciLz4KPHJlY3QgeD0iMzYuMiIgeT0iNjkuNyIgd2lkdGg9IjMiIGhlaWdodD0iNCIgZmlsbD0iI0ZGREUwMiIgb3BhY2l0eT0iMC43Ii8+CjxyZWN0IHg9IjI4LjgiIHk9IjY5LjIiIHdpZHRoPSIzIiBoZWlnaHQ9IjQiIGZpbGw9IiNGRkRFMDIiIG9wYWNpdHk9IjAuNyIvPgo8cmVjdCB4PSIzNi4yIiB5PSI1Ny43IiB3aWR0aD0iMyIgaGVpZ2h0PSI0IiBmaWxsPSIjRkZERTAyIiBvcGFjaXR5PSIwLjciLz4KPHJlY3QgeD0iMjguOCIgeT0iNTcuMiIgd2lkdGg9IjMiIGhlaWdodD0iNCIgZmlsbD0iI0ZGREUwMiIgb3BhY2l0eT0iMC43Ii8+Cgo8L3N2Zz4=', |
|
'building-small.svg': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NCIgaGVpZ2h0PSI5NiI+CjxwYXRoIGQ9Ik0zOC45LDM4LjAgTDQwLjcsNDcuMCBMMjUuMSw0Ni4wIEwyMy4zLDM3LjAgWiIgZmlsbD0iIzlERDhGRiIvPgo8cGF0aCBkPSJNNDAuNyw0Ny4wIEwyNS4xLDQ2LjAgTDI1LjEsNzYuMCBMNDAuNyw3Ny4wIFoiIGZpbGw9IiM2RkMyRkYiLz4KPHBhdGggZD0iTTI1LjEsNDYuMCBMMjMuMywzNy4wIEwyMy4zLDY3LjAgTDI1LjEsNzYuMCBaIiBmaWxsPSIjNEE5QUQ4Ii8+CjxyZWN0IHg9IjM1LjMiIHk9IjY4LjgiIHdpZHRoPSIzIiBoZWlnaHQ9IjQiIGZpbGw9IiNGRkRFMDIiIG9wYWNpdHk9IjAuNyIvPgo8cmVjdCB4PSIyOS4wIiB5PSI2OC4zIiB3aWR0aD0iMyIgaGVpZ2h0PSI0IiBmaWxsPSIjRkZERTAyIiBvcGFjaXR5PSIwLjciLz4KPHJlY3QgeD0iMzUuMyIgeT0iNTYuOCIgd2lkdGg9IjMiIGhlaWdodD0iNCIgZmlsbD0iI0ZGREUwMiIgb3BhY2l0eT0iMC43Ii8+CjxyZWN0IHg9IjI5LjAiIHk9IjU2LjMiIHdpZHRoPSIzIiBoZWlnaHQ9IjQiIGZpbGw9IiNGRkRFMDIiIG9wYWNpdHk9IjAuNyIvPgoKPC9zdmc+', |
|
'factory.svg': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4MCIgaGVpZ2h0PSIxMTIiPgo8cGF0aCBkPSJNNTAuNCwzNy40IEw1My4wLDUwLjkgTDI5LjYsNDkuNCBMMjcuMCwzNS45IFoiIGZpbGw9IiM4ODgiLz4KPHBhdGggZD0iTTUzLjAsNTAuOSBMMjkuNiw0OS40IEwyOS42LDg0LjQgTDUzLjAsODUuOSBaIiBmaWxsPSIjNTU1Ii8+CjxwYXRoIGQ9Ik0yOS42LDQ5LjQgTDI3LjAsMzUuOSBMMjcuMCw3MC45IEwyOS42LDg0LjQgWiIgZmlsbD0iIzQ0NCIvPgo8cGF0aCBkPSJNNDkuNywzMi40IEw1MC4yLDM0LjYgTDQ2LjMsMzQuNCBMNDUuOCwzMi4xIFoiIGZpbGw9IiM3NzciLz4KPHBhdGggZD0iTTUwLjIsMzQuNiBMNDYuMywzNC40IEw0Ni4zLDQ5LjQgTDUwLjIsNDkuNiBaIiBmaWxsPSIjNTU1Ii8+CjxwYXRoIGQ9Ik00Ni4zLDM0LjQgTDQ1LjgsMzIuMSBMNDUuOCw0Ny4xIEw0Ni4zLDQ5LjQgWiIgZmlsbD0iIzQ0NCIvPgo8Y2lyY2xlIGN4PSI0OC4wIiBjeT0iMzMuNCIgcj0iMy4wIiBmaWxsPSIjYWFhIiBvcGFjaXR5PSIwLjMiLz4KPGNpcmNsZSBjeD0iNTEuNSIgY3k9IjI3LjQiIHI9IjQuNSIgZmlsbD0iI2FhYSIgb3BhY2l0eT0iMC4zIi8+CjxjaXJjbGUgY3g9IjQ0LjUiIGN5PSIyMS40IiByPSI2LjAiIGZpbGw9IiNhYWEiIG9wYWNpdHk9IjAuMyIvPgoKPC9zdmc+', |
|
'park.svg': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI2NCIgaGVpZ2h0PSI4MCI+CjxwYXRoIGQ9Ik00MC4yLDUxLjIgTDQyLjQsNjIuMCBMMjMuOCw2MC44IEwyMS42LDUwLjAgWiIgZmlsbD0iIzFkZDRiNiIvPgo8cGF0aCBkPSJNNDIuNCw2Mi4wIEwyMy44LDYwLjggTDIzLjgsNjQuOCBMNDIuNCw2Ni4wIFoiIGZpbGw9IiMxNkFBOTgiLz4KPHBhdGggZD0iTTIzLjgsNjAuOCBMMjEuNiw1MC4wIEwyMS42LDU0LjAgTDIzLjgsNjQuOCBaIiBmaWxsPSIjMGQ4YTdhIi8+CjxyZWN0IHg9IjMwLjUiIHk9IjMyLjAiIHdpZHRoPSIzIiBoZWlnaHQ9IjEyIiBmaWxsPSIjOEI2OTE0Ii8+CjxwYXRoIGQ9Ik0zMi4wLDIwLjAgTDI0LjAsNDAuMCBMNDAuMCw0MC4wIFoiIGZpbGw9IiMwZDhhN2EiLz4KPC9zdmc+', |
|
'warehouse.svg': 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSI4MCIgaGVpZ2h0PSI5NiI+CjxwYXRoIGQ9Ik00OC43LDM3LjIgTDUzLjksNTAuMiBMMzEuMyw0Ny4yIEwyNi4xLDM0LjIgWiIgZmlsbD0iI0ZGOUI5NSIvPgo8cGF0aCBkPSJNNTMuOSw1MC4yIEwzMS4zLDQ3LjIgTDMxLjMsNzIuMiBMNTMuOSw3NS4yIFoiIGZpbGw9IiNGRjcxNjkiLz4KPHBhdGggZD0iTTMxLjMsNDcuMiBMMjYuMSwzNC4yIEwyNi4xLDU5LjIgTDMxLjMsNzIuMiBaIiBmaWxsPSIjRTU1NTREIi8+CjxsaW5lIHgxPSI0OC45IiB5MT0iNTguNSIgeDI9IjU4LjkiIHkyPSI1OC41IiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjAuOCIgb3BhY2l0eT0iMC40Ii8+CjxsaW5lIHgxPSI0OC45IiB5MT0iNjYuOSIgeDI9IjU4LjkiIHkyPSI2Ni45IiBzdHJva2U9IndoaXRlIiBzdHJva2Utd2lkdGg9IjAuOCIgb3BhY2l0eT0iMC40Ii8+Cgo8L3N2Zz4=', |
|
'building-small.png': 'data:image/png;base64,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', |
|
'building-medium.png': 'data:image/png;base64,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', |
|
'factory.png': 'data:image/png;base64,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', |
|
}; |
|
|
|
// === CONSTANTS === |
|
const COLORS = { |
|
sun: '#FFDE02', sky: '#6FC2FF', garden: '#16AA98', |
|
watermelon: '#FF7169', neutral: '#383838', bg: '#1a1a2e' |
|
}; |
|
|
|
const TYPE_COLORS = { |
|
model: COLORS.sky, source: '#666', seed: COLORS.garden, snapshot: COLORS.watermelon |
|
}; |
|
|
|
const TYPE_BADGES = { |
|
model: { bg: '#6FC2FF', label: 'MODEL' }, |
|
source: { bg: '#888', label: 'SOURCE' }, |
|
seed: { bg: '#16AA98', label: 'SEED' }, |
|
snapshot: { bg: '#FF7169', label: 'SNAPSHOT' } |
|
}; |
|
|
|
// === STATE === |
|
let canvas, ctx, pickCanvas, pickCtx; |
|
let width, height; |
|
let transform = { x: 0, y: 0, scale: 1 }; |
|
let nodes = [], edges = [], nodeMap = {}; |
|
let schemaGroups = {}; |
|
let selectedNode = null; |
|
let highlightedNodes = new Set(); |
|
let highlightedEdges = new Set(); |
|
let hoveredNode = null; |
|
let searchTerm = ''; |
|
let streetGridH = []; // y-positions of horizontal streets (world coords) |
|
let streetGridV = []; // x-positions of vertical streets (world coords) |
|
|
|
// === SPRITE MANAGER === |
|
const spriteManager = { |
|
enabled: false, |
|
sprites: {}, // name -> { img: Image, anchor: {x, y}, w, h, loaded: boolean } |
|
manifest: null, |
|
typeMapping: null, |
|
roadMapping: null, |
|
|
|
async loadManifest(url) { |
|
try { |
|
// Try inline manifest first (self-contained mode), then fetch |
|
if (typeof INLINE_MANIFEST !== 'undefined' && INLINE_MANIFEST) { |
|
this.manifest = INLINE_MANIFEST; |
|
} else { |
|
const resp = await fetch(url || 'sprites/sprite-manifest.json'); |
|
this.manifest = await resp.json(); |
|
} |
|
this.typeMapping = this.manifest.typeMapping; |
|
this.roadMapping = this.manifest.roadMapping || null; |
|
const basePath = (url || 'sprites/sprite-manifest.json').replace(/[^/]*$/, ''); |
|
const promises = []; |
|
for (const [name, def] of Object.entries(this.manifest.sprites)) { |
|
// Use inline sprite data URI if available, otherwise fetch from path |
|
const inlineSrc = (typeof INLINE_SPRITES !== 'undefined') && INLINE_SPRITES[def.file]; |
|
const src = inlineSrc || (basePath + def.file); |
|
promises.push(this.loadSprite(name, src, def)); |
|
} |
|
await Promise.allSettled(promises); |
|
this.updateUI(); |
|
} catch (e) { |
|
console.warn('Failed to load sprite manifest:', e); |
|
document.getElementById('sprite-status').textContent = 'Manifest load failed'; |
|
throw e; // Re-throw so fallback can trigger |
|
} |
|
}, |
|
|
|
loadSprite(name, src, def) { |
|
return new Promise((resolve) => { |
|
const img = new Image(); |
|
img.onload = () => { |
|
this.sprites[name] = { |
|
img, loaded: true, |
|
w: def.width, h: def.height, |
|
anchor: { x: def.anchorX, y: def.anchorY } |
|
}; |
|
resolve(true); |
|
}; |
|
img.onerror = () => { |
|
this.sprites[name] = { img: null, loaded: false, w: def.width, h: def.height, anchor: { x: def.anchorX, y: def.anchorY } }; |
|
resolve(false); |
|
}; |
|
img.src = src; |
|
}); |
|
}, |
|
|
|
addSpriteFromFile(file) { |
|
return new Promise((resolve) => { |
|
const name = file.name.replace(/\.(png|webp|jpg|jpeg)$/i, ''); |
|
const img = new Image(); |
|
const url = URL.createObjectURL(file); |
|
img.onload = () => { |
|
this.sprites[name] = { |
|
img, loaded: true, |
|
w: img.naturalWidth, h: img.naturalHeight, |
|
anchor: { x: img.naturalWidth / 2, y: img.naturalHeight * 0.8 } |
|
}; |
|
this.updateUI(); |
|
resolve(true); |
|
}; |
|
img.onerror = () => resolve(false); |
|
img.src = url; |
|
}); |
|
}, |
|
|
|
getSpriteForNode(node) { |
|
if (!this.enabled || !this.typeMapping) return null; |
|
const mapping = this.typeMapping[node.type]; |
|
if (!mapping) return null; |
|
|
|
let spriteName; |
|
if (mapping.default) { |
|
spriteName = mapping.default; |
|
} else if (mapping.sizeThresholds) { |
|
const connections = node.childCount + node.parentCount; |
|
const [small, large] = mapping.sizeThresholds; |
|
if (connections <= small) spriteName = mapping.small; |
|
else if (connections <= large) spriteName = mapping.medium; |
|
else spriteName = mapping.large; |
|
} |
|
|
|
const sprite = this.sprites[spriteName]; |
|
return (sprite && sprite.loaded) ? sprite : null; |
|
}, |
|
|
|
getSpriteBounds(sprite, cx, cy, node) { |
|
const h = buildingHeight(node); |
|
const w = buildingWidth(node); |
|
const scaleX = (w * 2.5) / sprite.w; |
|
const scaleY = (h * 1.8) / sprite.h; |
|
const scale = Math.max(scaleX, scaleY); |
|
const dw = sprite.w * scale; |
|
const dh = sprite.h * scale; |
|
const dx = cx - sprite.anchor.x * scale; |
|
const dy = cy - sprite.anchor.y * scale; |
|
return { dx, dy, dw, dh }; |
|
}, |
|
|
|
drawSprite(ctx, sprite, cx, cy, node, alpha) { |
|
const { dx, dy, dw, dh } = this.getSpriteBounds(sprite, cx, cy, node); |
|
ctx.globalAlpha = alpha; |
|
ctx.drawImage(sprite.img, dx, dy, dw, dh); |
|
ctx.globalAlpha = 1; |
|
}, |
|
|
|
getRoadSprite(trafficCount) { |
|
if (!this.enabled || !this.roadMapping) return null; |
|
const threshold = this.roadMapping.highwayThreshold || 4; |
|
const name = trafficCount >= threshold ? this.roadMapping.highway : this.roadMapping.default; |
|
const sprite = this.sprites[name]; |
|
return (sprite && sprite.loaded) ? sprite : null; |
|
}, |
|
|
|
updateUI() { |
|
const loaded = Object.values(this.sprites).filter(s => s.loaded).length; |
|
const total = Object.keys(this.sprites).length; |
|
document.getElementById('sprite-status').textContent = `${loaded}/${total} sprites loaded`; |
|
|
|
const grid = document.getElementById('sprite-previews'); |
|
grid.innerHTML = ''; |
|
for (const [name, sprite] of Object.entries(this.sprites)) { |
|
const item = document.createElement('div'); |
|
item.className = `sp-item ${sprite.loaded ? 'loaded' : 'missing'}`; |
|
const img = document.createElement('img'); |
|
if (sprite.loaded && sprite.img) { |
|
img.src = sprite.img.src; |
|
} else { |
|
img.src = 'data:image/svg+xml,' + encodeURIComponent('<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32"><text x="8" y="20" font-size="14" fill="#666">?</text></svg>'); |
|
} |
|
img.alt = name; |
|
item.appendChild(img); |
|
const label = document.createElement('div'); |
|
label.textContent = name; |
|
item.appendChild(label); |
|
grid.appendChild(item); |
|
} |
|
} |
|
}; |
|
|
|
function setupTexturePanel() { |
|
const toggle = document.getElementById('texture-toggle'); |
|
toggle.addEventListener('change', () => { |
|
spriteManager.enabled = toggle.checked; |
|
render(); |
|
}); |
|
|
|
document.getElementById('load-manifest-btn').addEventListener('click', async () => { |
|
document.getElementById('sprite-status').textContent = 'Loading manifest...'; |
|
await spriteManager.loadManifest(); |
|
render(); |
|
}); |
|
|
|
document.getElementById('load-sprites-btn').addEventListener('click', () => { |
|
document.getElementById('sprite-file-input').click(); |
|
}); |
|
|
|
document.getElementById('sprite-file-input').addEventListener('change', async (e) => { |
|
const files = Array.from(e.target.files); |
|
document.getElementById('sprite-status').textContent = `Loading ${files.length} file(s)...`; |
|
await Promise.all(files.map(f => spriteManager.addSpriteFromFile(f))); |
|
|
|
// Auto-create a simple type mapping if none exists |
|
if (!spriteManager.typeMapping) { |
|
spriteManager.typeMapping = {}; |
|
const names = Object.keys(spriteManager.sprites); |
|
for (const name of names) { |
|
if (name.startsWith('building') || name.startsWith('model')) { |
|
if (!spriteManager.typeMapping.model) { |
|
spriteManager.typeMapping.model = { default: name }; |
|
} |
|
} else if (name.startsWith('factory') || name.startsWith('source')) { |
|
spriteManager.typeMapping.source = { default: name }; |
|
} else if (name.startsWith('park') || name.startsWith('seed')) { |
|
spriteManager.typeMapping.seed = { default: name }; |
|
} else if (name.startsWith('warehouse') || name.startsWith('snapshot')) { |
|
spriteManager.typeMapping.snapshot = { default: name }; |
|
} |
|
} |
|
} |
|
render(); |
|
}); |
|
|
|
// Auto-load manifest on startup (uses INLINE_MANIFEST and INLINE_SPRITES when available) |
|
spriteManager.loadManifest().catch(() => { |
|
// fetch() fails on file:// protocol; load sprites directly using inline data |
|
console.log('Manifest fetch failed (file:// mode), loading sprites from inline data...'); |
|
const fallbackSprites = { |
|
'building-small': { file: 'building-small.svg', width: 64, height: 96, anchorX: 32, anchorY: 80 }, |
|
'building-medium': { file: 'building-medium.svg', width: 64, height: 128, anchorX: 32, anchorY: 96 }, |
|
'building-large': { file: 'building-large.svg', width: 80, height: 160, anchorX: 40, anchorY: 112 }, |
|
'factory': { file: 'factory.svg', width: 80, height: 112, anchorX: 40, anchorY: 88 }, |
|
'park': { file: 'park.svg', width: 64, height: 80, anchorX: 32, anchorY: 64 }, |
|
'warehouse': { file: 'warehouse.svg', width: 80, height: 96, anchorX: 40, anchorY: 72 }, |
|
}; |
|
spriteManager.typeMapping = { |
|
model: { small: 'building-small', medium: 'building-medium', large: 'building-large', sizeThresholds: [5, 12] }, |
|
source: { default: 'factory' }, |
|
seed: { default: 'park' }, |
|
snapshot: { default: 'warehouse' } |
|
}; |
|
return Promise.allSettled( |
|
Object.entries(fallbackSprites).map(([name, def]) => { |
|
const inlineSrc = (typeof INLINE_SPRITES !== 'undefined') && INLINE_SPRITES[def.file]; |
|
return spriteManager.loadSprite(name, inlineSrc || ('sprites/' + def.file), def); |
|
}) |
|
); |
|
}).then(() => { |
|
const loaded = Object.values(spriteManager.sprites).filter(s => s.loaded).length; |
|
if (loaded > 0) { |
|
toggle.checked = true; |
|
spriteManager.enabled = true; |
|
render(); |
|
} |
|
}); |
|
} |
|
|
|
// === ISOMETRIC HELPERS === |
|
function isoX(x, y) { return (x - y) * 0.866; } |
|
function isoY(x, y, z) { return (x + y) * 0.5 - z; } |
|
|
|
function buildingHeight(node) { |
|
const connections = node.childCount + node.parentCount; |
|
if (node.type === 'source') return 30 + Math.min(connections * 5, 40); |
|
if (node.type === 'seed') return 10; |
|
if (node.type === 'snapshot') return 22; |
|
return 22 + Math.log2(connections + 1) * 20; |
|
} |
|
|
|
function buildingWidth(node) { |
|
if (node.type === 'source') return 28; |
|
if (node.type === 'seed') return 22; |
|
if (node.type === 'snapshot') return 32; |
|
const connections = node.childCount + node.parentCount; |
|
return 20 + Math.min(connections * 1.5, 18); |
|
} |
|
|
|
function buildingDepth(node) { |
|
if (node.type === 'snapshot') return 24; |
|
return buildingWidth(node) * 0.85; |
|
} |
|
|
|
// === DRAWING FUNCTIONS === |
|
function darken(hex, amt) { |
|
let r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16); |
|
r = Math.max(0, r - amt); g = Math.max(0, g - amt); b = Math.max(0, b - amt); |
|
return `rgb(${r},${g},${b})`; |
|
} |
|
|
|
function lighten(hex, amt) { |
|
let r = parseInt(hex.slice(1,3),16), g = parseInt(hex.slice(3,5),16), b = parseInt(hex.slice(5,7),16); |
|
r = Math.min(255, r + amt); g = Math.min(255, g + amt); b = Math.min(255, b + amt); |
|
return `rgb(${r},${g},${b})`; |
|
} |
|
|
|
function drawIsoPrism(ctx, cx, cy, w, d, h, color, alpha) { |
|
ctx.globalAlpha = alpha; |
|
const hw = w / 2, hd = d / 2; |
|
|
|
// Four diamond corners on the ground plane (N, E, S, W of the footprint) |
|
const north = { x: cx + isoX(0, -hd), y: cy + isoY(0, -hd, 0) }; |
|
const east = { x: cx + isoX(hw, 0), y: cy + isoY(hw, 0, 0) }; |
|
const south = { x: cx + isoX(0, hd), y: cy + isoY(0, hd, 0) }; |
|
const west = { x: cx + isoX(-hw, 0), y: cy + isoY(-hw, 0, 0) }; |
|
|
|
// Top face (diamond at height h) |
|
ctx.beginPath(); |
|
ctx.moveTo(north.x, north.y - h); |
|
ctx.lineTo(east.x, east.y - h); |
|
ctx.lineTo(south.x, south.y - h); |
|
ctx.lineTo(west.x, west.y - h); |
|
ctx.closePath(); |
|
ctx.fillStyle = lighten(color, 40); |
|
ctx.fill(); |
|
|
|
// Right face (east -> south wall, facing viewer's right) |
|
ctx.beginPath(); |
|
ctx.moveTo(east.x, east.y - h); |
|
ctx.lineTo(south.x, south.y - h); |
|
ctx.lineTo(south.x, south.y); |
|
ctx.lineTo(east.x, east.y); |
|
ctx.closePath(); |
|
ctx.fillStyle = darken(color, 20); |
|
ctx.fill(); |
|
|
|
// Left face (south -> west wall, facing viewer's left) |
|
ctx.beginPath(); |
|
ctx.moveTo(south.x, south.y - h); |
|
ctx.lineTo(west.x, west.y - h); |
|
ctx.lineTo(west.x, west.y); |
|
ctx.lineTo(south.x, south.y); |
|
ctx.closePath(); |
|
ctx.fillStyle = darken(color, 50); |
|
ctx.fill(); |
|
|
|
// Edge outlines for definition |
|
ctx.globalAlpha = alpha * 0.3; |
|
ctx.strokeStyle = darken(color, 70); |
|
ctx.lineWidth = 0.5; |
|
// Bottom edge |
|
ctx.beginPath(); |
|
ctx.moveTo(east.x, east.y); ctx.lineTo(south.x, south.y); ctx.lineTo(west.x, west.y); |
|
ctx.stroke(); |
|
// Vertical edges |
|
ctx.beginPath(); |
|
ctx.moveTo(south.x, south.y); ctx.lineTo(south.x, south.y - h); |
|
ctx.moveTo(east.x, east.y); ctx.lineTo(east.x, east.y - h); |
|
ctx.moveTo(west.x, west.y); ctx.lineTo(west.x, west.y - h); |
|
ctx.stroke(); |
|
// Top edge |
|
ctx.beginPath(); |
|
ctx.moveTo(north.x, north.y - h); ctx.lineTo(east.x, east.y - h); |
|
ctx.lineTo(south.x, south.y - h); ctx.lineTo(west.x, west.y - h); |
|
ctx.closePath(); |
|
ctx.stroke(); |
|
|
|
ctx.globalAlpha = 1; |
|
} |
|
|
|
function drawFactory(ctx, cx, cy, node, alpha) { |
|
const w = buildingWidth(node), d = buildingDepth(node), h = buildingHeight(node); |
|
// Main factory body |
|
drawIsoPrism(ctx, cx, cy, w, d, h * 0.7, '#555', alpha); |
|
|
|
// Raised section (smaller, on top) |
|
const raisedX = cx + isoX(-w * 0.1, -d * 0.1); |
|
const raisedY = cy + isoY(-w * 0.1, -d * 0.1, 0); |
|
drawIsoPrism(ctx, raisedX, raisedY - h * 0.7, w * 0.5, d * 0.5, h * 0.35, '#606060', alpha); |
|
|
|
// Chimney |
|
const chimneyX = cx + isoX(w * 0.2, -d * 0.2); |
|
const chimneyBaseY = cy + isoY(w * 0.2, -d * 0.2, 0) - h * 0.7; |
|
drawIsoPrism(ctx, chimneyX, chimneyBaseY, 4, 3, 14, '#4a4a4a', alpha); |
|
|
|
// Smoke puffs |
|
ctx.globalAlpha = alpha * 0.2; |
|
ctx.fillStyle = '#bbb'; |
|
for (let i = 0; i < 4; i++) { |
|
const sx = chimneyX + Math.sin(i * 1.8 + node.x * 0.01) * 5; |
|
const sy = chimneyBaseY - 14 - i * 6; |
|
const sr = 2.5 + i * 1.5; |
|
ctx.beginPath(); |
|
ctx.arc(sx, sy, sr, 0, Math.PI * 2); |
|
ctx.fill(); |
|
} |
|
|
|
// Pipe/duct detail on side |
|
ctx.globalAlpha = alpha * 0.3; |
|
ctx.strokeStyle = '#888'; |
|
ctx.lineWidth = 1.5; |
|
const pipeStart = { x: cx + isoX(w/2, d * 0.1), y: cy + isoY(w/2, d * 0.1, 0) - h * 0.3 }; |
|
const pipeEnd = { x: cx + isoX(w/2, d * 0.3), y: cy + isoY(w/2, d * 0.3, 0) - h * 0.3 }; |
|
ctx.beginPath(); |
|
ctx.moveTo(pipeStart.x, pipeStart.y); |
|
ctx.lineTo(pipeEnd.x, pipeEnd.y); |
|
ctx.stroke(); |
|
|
|
ctx.globalAlpha = 1; |
|
} |
|
|
|
function drawPark(ctx, cx, cy, node, alpha) { |
|
const w = buildingWidth(node), d = buildingDepth(node); |
|
// Green base (low, like a lawn) |
|
drawIsoPrism(ctx, cx, cy, w, d, 2, '#1a9a6a', alpha); |
|
|
|
// Multiple trees scattered on the park |
|
const treePositions = [ |
|
{ dx: 0, dy: 0, scale: 1.0 }, |
|
{ dx: -w * 0.25, dy: -d * 0.15, scale: 0.7 }, |
|
{ dx: w * 0.2, dy: d * 0.12, scale: 0.8 }, |
|
]; |
|
|
|
for (const tree of treePositions) { |
|
const tx = cx + isoX(tree.dx, tree.dy); |
|
const ty = cy + isoY(tree.dx, tree.dy, 2); // on top of base |
|
const s = tree.scale; |
|
|
|
// Trunk |
|
ctx.globalAlpha = alpha; |
|
ctx.fillStyle = '#6B5214'; |
|
ctx.fillRect(tx - 1, ty - 10 * s, 2, 7 * s); |
|
|
|
// Canopy (layered circles for a rounder look) |
|
ctx.globalAlpha = alpha * 0.85; |
|
ctx.fillStyle = '#0d8a5a'; |
|
ctx.beginPath(); |
|
ctx.arc(tx, ty - 14 * s, 5 * s, 0, Math.PI * 2); |
|
ctx.fill(); |
|
ctx.fillStyle = '#10a070'; |
|
ctx.beginPath(); |
|
ctx.arc(tx - 1.5 * s, ty - 12 * s, 3.5 * s, 0, Math.PI * 2); |
|
ctx.fill(); |
|
} |
|
ctx.globalAlpha = 1; |
|
} |
|
|
|
function drawWarehouse(ctx, cx, cy, node, alpha) { |
|
const w = buildingWidth(node), d = buildingDepth(node), h = buildingHeight(node); |
|
drawIsoPrism(ctx, cx, cy, w, d, h, COLORS.watermelon, alpha); |
|
|
|
// Loading dock doors on right face |
|
ctx.globalAlpha = alpha * 0.35; |
|
const east = { x: cx + isoX(w/2, 0), y: cy + isoY(w/2, 0, 0) }; |
|
const south = { x: cx + isoX(0, d/2), y: cy + isoY(0, d/2, 0) }; |
|
ctx.fillStyle = darken(COLORS.watermelon, 40); |
|
// Draw 2 bay doors |
|
for (let di = 0; di < 2; di++) { |
|
const t = 0.25 + di * 0.4; |
|
const dx = east.x + (south.x - east.x) * t; |
|
const dy = east.y + (south.y - east.y) * t; |
|
ctx.fillRect(dx - 2, dy - h * 0.6, 4, h * 0.55); |
|
} |
|
|
|
// Horizontal ribbing on left face |
|
ctx.strokeStyle = 'rgba(255,255,255,0.2)'; |
|
ctx.lineWidth = 0.5; |
|
const west = { x: cx + isoX(-w/2, 0), y: cy + isoY(-w/2, 0, 0) }; |
|
for (let i = 1; i <= 3; i++) { |
|
const t = i / 4; |
|
const ly = -h * t; |
|
ctx.beginPath(); |
|
ctx.moveTo(south.x, south.y + ly); |
|
ctx.lineTo(west.x, west.y + ly); |
|
ctx.stroke(); |
|
} |
|
ctx.globalAlpha = 1; |
|
} |
|
|
|
// Building color palette by schema (varies city look beyond all-blue) |
|
const BUILDING_PALETTES = [ |
|
['#6FC2FF', '#4a9ad8', '#2a7abf'], // Sky blue (default) |
|
['#8a9aaa', '#7a8a98', '#6a7a88'], // Warm gray |
|
['#5ab88a', '#48a078', '#388868'], // Garden green |
|
['#c89078', '#b87868', '#a86858'], // Warm terracotta |
|
['#9a8abf', '#8878aa', '#786898'], // Soft purple |
|
]; |
|
|
|
function getBuildingColor(node) { |
|
// Get schema index for color variety |
|
const blocks = window._blockPositions; |
|
let schemaIdx = 0; |
|
if (blocks) { |
|
const schemas = Object.keys(blocks); |
|
schemaIdx = schemas.indexOf(node.schema); |
|
if (schemaIdx < 0) schemaIdx = 0; |
|
} |
|
const palette = BUILDING_PALETTES[schemaIdx % BUILDING_PALETTES.length]; |
|
const connections = node.childCount + node.parentCount; |
|
if (connections > 15) return palette[2]; |
|
if (connections > 8) return palette[1]; |
|
return palette[0]; |
|
} |
|
|
|
function drawBuilding(ctx, cx, cy, node, alpha) { |
|
const w = buildingWidth(node), d = buildingDepth(node), h = buildingHeight(node); |
|
const color = getBuildingColor(node); |
|
|
|
drawIsoPrism(ctx, cx, cy, w, d, h, color, alpha); |
|
|
|
// Simple hash from node name for window randomness |
|
let hash = 0; |
|
for (let i = 0; i < node.name.length; i++) hash = ((hash << 5) - hash + node.name.charCodeAt(i)) | 0; |
|
|
|
// Windows on right (SE) face |
|
if (h > 20) { |
|
ctx.globalAlpha = alpha * 0.55; |
|
ctx.fillStyle = COLORS.sun; |
|
const east = { x: cx + isoX(w/2, 0), y: cy + isoY(w/2, 0, 0) }; |
|
const south = { x: cx + isoX(0, d/2), y: cy + isoY(0, d/2, 0) }; |
|
const floors = Math.floor(h / 10); |
|
const windowsPerFloor = Math.min(3, Math.ceil(w / 10)); |
|
let winIdx = 0; |
|
for (let f = 0; f < floors; f++) { |
|
const fy = -5 - f * 10; |
|
for (let wi = 0; wi < windowsPerFloor; wi++) { |
|
// Some windows are dark (lights off) |
|
if (((hash >> (winIdx++ % 16)) & 1) === 0) continue; |
|
const t = (wi + 1) / (windowsPerFloor + 1); |
|
const wx = east.x + (south.x - east.x) * t; |
|
const wy = east.y + (south.y - east.y) * t + fy; |
|
ctx.fillRect(wx - 1.2, wy, 2.4, 3); |
|
} |
|
} |
|
} |
|
|
|
// Windows on left (SW) face |
|
if (h > 30) { |
|
ctx.globalAlpha = alpha * 0.35; |
|
ctx.fillStyle = COLORS.sun; |
|
const south = { x: cx + isoX(0, d/2), y: cy + isoY(0, d/2, 0) }; |
|
const west = { x: cx + isoX(-w/2, 0), y: cy + isoY(-w/2, 0, 0) }; |
|
const floors = Math.floor(h / 12); |
|
let winIdx = 7; // different seed offset |
|
for (let f = 0; f < floors; f++) { |
|
const fy = -5 - f * 12; |
|
for (let wi = 0; wi < 2; wi++) { |
|
if (((hash >> (winIdx++ % 16)) & 1) === 0) continue; |
|
const t = 0.3 + wi * 0.35; |
|
const wx = south.x + (west.x - south.x) * t; |
|
const wy = south.y + (west.y - south.y) * t + fy; |
|
ctx.fillRect(wx - 1, wy, 2, 2.5); |
|
} |
|
} |
|
} |
|
|
|
// Roof detail for tall buildings (AC unit or antenna) |
|
if (h > 45 && alpha > 0.5) { |
|
ctx.globalAlpha = alpha * 0.5; |
|
const roofCx = cx + isoX(w * 0.15, d * 0.1); |
|
const roofCy = cy + isoY(w * 0.15, d * 0.1, 0) - h; |
|
drawIsoPrism(ctx, roofCx, roofCy, 3, 3, 4, darken(color, 30), alpha * 0.7); |
|
} |
|
|
|
ctx.globalAlpha = 1; |
|
} |
|
|
|
function drawNode(ctx, node, alpha) { |
|
const sx = isoX(node.x, node.y); |
|
const sy = isoY(node.x, node.y, 0); |
|
|
|
// Shadows are drawn in the pre-pass in render() |
|
|
|
// Try sprite texture first |
|
const sprite = spriteManager.getSpriteForNode(node); |
|
if (sprite) { |
|
spriteManager.drawSprite(ctx, sprite, sx, sy, node, alpha); |
|
return; |
|
} |
|
|
|
switch (node.type) { |
|
case 'source': drawFactory(ctx, sx, sy, node, alpha); break; |
|
case 'seed': drawPark(ctx, sx, sy, node, alpha); break; |
|
case 'snapshot': drawWarehouse(ctx, sx, sy, node, alpha); break; |
|
default: drawBuilding(ctx, sx, sy, node, alpha); break; |
|
} |
|
} |
|
|
|
// === SCHEMA NEIGHBORHOOD RENDERING === |
|
function drawSchemaNeighborhoods(ctx) { |
|
const blocks = window._blockPositions; |
|
if (!blocks) return; |
|
|
|
const palette = ['#6FC2FF','#FFDE02','#16AA98','#FF7169','#9b59b6','#e67e22','#1abc9c','#e74c3c','#3498db','#2ecc71','#f39c12','#8e44ad','#d35400','#27ae60','#c0392b','#2980b9','#f1c40f','#7f8c8d']; |
|
let ci = 0; |
|
|
|
for (const [schema, bp] of Object.entries(blocks)) { |
|
const color = palette[ci++ % palette.length]; |
|
const pad = 24; |
|
|
|
const x0 = bp.x - pad, y0 = bp.y - pad; |
|
const x1 = bp.x + bp.w + pad, y1 = bp.y + bp.h + pad; |
|
|
|
const corners = [ |
|
{ x: isoX(x0, y0), y: isoY(x0, y0, 0) }, |
|
{ x: isoX(x1, y0), y: isoY(x1, y0, 0) }, |
|
{ x: isoX(x1, y1), y: isoY(x1, y1, 0) }, |
|
{ x: isoX(x0, y1), y: isoY(x0, y1, 0) }, |
|
]; |
|
|
|
const drawDiamond = () => { |
|
ctx.beginPath(); |
|
corners.forEach((c, i) => i === 0 ? ctx.moveTo(c.x, c.y) : ctx.lineTo(c.x, c.y)); |
|
ctx.closePath(); |
|
}; |
|
|
|
// Zone border |
|
ctx.globalAlpha = 0.5; |
|
ctx.strokeStyle = color; |
|
ctx.lineWidth = 2.5; |
|
drawDiamond(); |
|
ctx.stroke(); |
|
|
|
// SimCity 2000-style signpost label (at the front/south of the neighborhood) |
|
const labelX = corners[2].x; |
|
const labelY = corners[2].y - 12; |
|
ctx.font = 'bold 10px "Courier New", monospace'; |
|
ctx.textAlign = 'center'; |
|
const textW = ctx.measureText(schema).width; |
|
const signPad = 5; |
|
const signW = textW + signPad * 2; |
|
const signH = 14; |
|
const signTop = labelY - signH + 2; |
|
|
|
// Post/pole |
|
ctx.globalAlpha = 0.8; |
|
ctx.strokeStyle = color; |
|
ctx.lineWidth = 1.5; |
|
ctx.beginPath(); |
|
ctx.moveTo(labelX, labelY + 2); |
|
ctx.lineTo(labelX, labelY + 14); |
|
ctx.stroke(); |
|
|
|
// Sign background (neighborhood color) |
|
ctx.globalAlpha = 0.92; |
|
ctx.fillStyle = color; |
|
ctx.fillRect(labelX - signW / 2, signTop, signW, signH); |
|
|
|
// Darker border |
|
ctx.globalAlpha = 0.5; |
|
ctx.strokeStyle = '#222'; |
|
ctx.lineWidth = 1; |
|
ctx.strokeRect(labelX - signW / 2, signTop, signW, signH); |
|
|
|
// Sign text in white |
|
ctx.globalAlpha = 1; |
|
ctx.fillStyle = '#fff'; |
|
ctx.fillText(schema, labelX, labelY); |
|
} |
|
ctx.globalAlpha = 1; |
|
} |
|
|
|
// === EDGE/ROAD DRAWING (Manhattan-style with rounded corners) === |
|
|
|
// Draw a Manhattan path in isometric space with rounded corners |
|
function traceRoadPath(ctx, road) { |
|
const pts = road.points; |
|
// Project all points to isometric screen coords |
|
const iso = pts.map(p => ({ x: isoX(p.x, p.y), y: isoY(p.x, p.y, 0) })); |
|
|
|
if (iso.length < 2) return; |
|
|
|
const RADIUS = 6; // corner rounding radius |
|
|
|
ctx.beginPath(); |
|
ctx.moveTo(iso[0].x, iso[0].y); |
|
|
|
for (let i = 1; i < iso.length; i++) { |
|
if (i < iso.length - 1) { |
|
// This is a corner point: use arcTo for a rounded turn |
|
const prev = iso[i - 1], curr = iso[i], next = iso[i + 1]; |
|
ctx.arcTo(curr.x, curr.y, next.x, next.y, RADIUS); |
|
} else { |
|
ctx.lineTo(iso[i].x, iso[i].y); |
|
} |
|
} |
|
ctx.stroke(); |
|
} |
|
|
|
function drawRoadSurface(ctx, seg, halfW, alpha) { |
|
// Draw a filled isometric parallelogram on the ground plane for the road surface |
|
let corners; |
|
if (seg.type === 'h') { |
|
// Horizontal corridor: runs along X, fixed Y=seg.coord, width = halfW in Y |
|
corners = [ |
|
{ x: isoX(seg.min, seg.coord - halfW), y: isoY(seg.min, seg.coord - halfW, 0) }, |
|
{ x: isoX(seg.max, seg.coord - halfW), y: isoY(seg.max, seg.coord - halfW, 0) }, |
|
{ x: isoX(seg.max, seg.coord + halfW), y: isoY(seg.max, seg.coord + halfW, 0) }, |
|
{ x: isoX(seg.min, seg.coord + halfW), y: isoY(seg.min, seg.coord + halfW, 0) }, |
|
]; |
|
} else { |
|
// Vertical corridor: runs along Y, fixed X=seg.coord, width = halfW in X |
|
corners = [ |
|
{ x: isoX(seg.coord - halfW, seg.min), y: isoY(seg.coord - halfW, seg.min, 0) }, |
|
{ x: isoX(seg.coord + halfW, seg.min), y: isoY(seg.coord + halfW, seg.min, 0) }, |
|
{ x: isoX(seg.coord + halfW, seg.max), y: isoY(seg.coord + halfW, seg.max, 0) }, |
|
{ x: isoX(seg.coord - halfW, seg.max), y: isoY(seg.coord - halfW, seg.max, 0) }, |
|
]; |
|
} |
|
|
|
ctx.globalAlpha = alpha; |
|
ctx.beginPath(); |
|
corners.forEach((c, i) => i === 0 ? ctx.moveTo(c.x, c.y) : ctx.lineTo(c.x, c.y)); |
|
ctx.closePath(); |
|
ctx.fill(); |
|
|
|
// Edge lines |
|
ctx.globalAlpha = alpha * 0.5; |
|
ctx.stroke(); |
|
ctx.globalAlpha = 1; |
|
} |
|
|
|
function drawRoadSpriteTiled(ctx, seg, halfW, sprite, alpha) { |
|
// Tile a road sprite along the corridor segment |
|
// Each tile covers one CELL-sized chunk of road in world space |
|
const TILE_SIZE = 55; // match CELL |
|
const length = seg.max - seg.min; |
|
const tiles = Math.max(1, Math.ceil(length / TILE_SIZE)); |
|
|
|
ctx.globalAlpha = alpha; |
|
|
|
for (let t = 0; t < tiles; t++) { |
|
const progress = tiles === 1 ? 0.5 : t / (tiles - 1); |
|
let cx, cy; |
|
|
|
if (seg.type === 'h') { |
|
const worldX = seg.min + progress * length; |
|
cx = isoX(worldX, seg.coord); |
|
cy = isoY(worldX, seg.coord, 0); |
|
} else { |
|
const worldY = seg.min + progress * length; |
|
cx = isoX(seg.coord, worldY); |
|
cy = isoY(seg.coord, worldY, 0); |
|
} |
|
|
|
// Scale sprite to cover the tile area |
|
const scale = (halfW * 2 * 1.8) / sprite.w; // scale to match road width in iso |
|
const dw = sprite.w * scale; |
|
const dh = sprite.h * scale; |
|
const dx = cx - sprite.anchor.x * scale; |
|
const dy = cy - sprite.anchor.y * scale; |
|
|
|
ctx.drawImage(sprite.img, dx, dy, dw, dh); |
|
} |
|
|
|
ctx.globalAlpha = 1; |
|
} |
|
|
|
function drawBundledSegment(ctx, seg) { |
|
// Fallback: draw as a simple line (used for center dashes) |
|
let x1, y1, x2, y2; |
|
if (seg.type === 'h') { |
|
x1 = isoX(seg.min, seg.coord); |
|
y1 = isoY(seg.min, seg.coord, 0); |
|
x2 = isoX(seg.max, seg.coord); |
|
y2 = isoY(seg.max, seg.coord, 0); |
|
} else { |
|
x1 = isoX(seg.coord, seg.min); |
|
y1 = isoY(seg.coord, seg.min, 0); |
|
x2 = isoX(seg.coord, seg.max); |
|
y2 = isoY(seg.coord, seg.max, 0); |
|
} |
|
ctx.beginPath(); |
|
ctx.moveTo(x1, y1); |
|
ctx.lineTo(x2, y2); |
|
ctx.stroke(); |
|
} |
|
|
|
function drawEdges(ctx) { |
|
if (!roadPaths) return; |
|
|
|
ctx.lineCap = 'round'; |
|
ctx.lineJoin = 'round'; |
|
|
|
const hasHighlight = highlightedEdges.size > 0; |
|
|
|
// Normal roads are drawn by drawStreetCorridors() as a proper street grid. |
|
// Here we only draw highlighted lineage paths. |
|
|
|
// --- Highlighted roads: draw individual paths for lineage --- |
|
if (hasHighlight) { |
|
// Glow |
|
ctx.strokeStyle = 'rgba(255,222,2,0.15)'; |
|
ctx.lineWidth = 12; |
|
for (const road of roadPaths) { |
|
if (!highlightedEdges.has(road.edgeId)) continue; |
|
traceRoadPath(ctx, road); |
|
} |
|
|
|
// Road bed |
|
ctx.strokeStyle = 'rgba(255,200,0,0.55)'; |
|
ctx.lineWidth = 6; |
|
for (const road of roadPaths) { |
|
if (!highlightedEdges.has(road.edgeId)) continue; |
|
traceRoadPath(ctx, road); |
|
} |
|
|
|
// Road surface |
|
ctx.strokeStyle = 'rgba(255,222,2,0.9)'; |
|
ctx.lineWidth = 3; |
|
for (const road of roadPaths) { |
|
if (!highlightedEdges.has(road.edgeId)) continue; |
|
traceRoadPath(ctx, road); |
|
} |
|
|
|
// Direction arrows |
|
ctx.fillStyle = 'rgba(255,222,2,0.95)'; |
|
for (const road of roadPaths) { |
|
if (!highlightedEdges.has(road.edgeId)) continue; |
|
const pts = road.points; |
|
if (pts.length < 2) continue; |
|
const p1 = pts[pts.length - 2], p2 = pts[pts.length - 1]; |
|
const ax = isoX((p1.x + p2.x) / 2, (p1.y + p2.y) / 2); |
|
const ay = isoY((p1.x + p2.x) / 2, (p1.y + p2.y) / 2, 0); |
|
const ex = isoX(p2.x, p2.y) - isoX(p1.x, p1.y); |
|
const ey = isoY(p2.x, p2.y, 0) - isoY(p1.x, p1.y, 0); |
|
const angle = Math.atan2(ey, ex); |
|
ctx.beginPath(); |
|
ctx.moveTo(ax + Math.cos(angle) * 5, ay + Math.sin(angle) * 5); |
|
ctx.lineTo(ax + Math.cos(angle + 2.5) * 4, ay + Math.sin(angle + 2.5) * 4); |
|
ctx.lineTo(ax + Math.cos(angle - 2.5) * 4, ay + Math.sin(angle - 2.5) * 4); |
|
ctx.closePath(); |
|
ctx.fill(); |
|
} |
|
} |
|
|
|
ctx.lineCap = 'butt'; |
|
} |
|
|
|
// === PICK CANVAS (HIT DETECTION) === |
|
function renderPickCanvas() { |
|
pickCtx.clearRect(0, 0, width, height); |
|
pickCtx.save(); |
|
pickCtx.translate(transform.x, transform.y); |
|
pickCtx.scale(transform.scale, transform.scale); |
|
|
|
// Build index map for color encoding, then sort back-to-front (same as render) |
|
const indexMap = new Map(); |
|
nodes.forEach((n, i) => indexMap.set(n, i)); |
|
const sorted = [...nodes].sort((a, b) => isoY(a.x, a.y, 0) - isoY(b.x, b.y, 0)); |
|
|
|
for (const node of sorted) { |
|
const i = indexMap.get(node); |
|
const sx = isoX(node.x, node.y); |
|
const sy = isoY(node.x, node.y, 0); |
|
const h = buildingHeight(node); |
|
const w = buildingWidth(node); |
|
|
|
// Encode index as color |
|
const r = (i + 1) & 0xFF; |
|
const g = ((i + 1) >> 8) & 0xFF; |
|
const b = ((i + 1) >> 16) & 0xFF; |
|
pickCtx.fillStyle = `rgb(${r},${g},${b})`; |
|
|
|
// Draw a rect covering the visible building area (not the full sprite image) |
|
pickCtx.fillRect(sx - w, sy - h - 5, w * 2, h + 10); |
|
} |
|
pickCtx.restore(); |
|
} |
|
|
|
function getNodeAtPixel(mx, my) { |
|
const pixel = pickCtx.getImageData(mx, my, 1, 1).data; |
|
const idx = pixel[0] + (pixel[1] << 8) + (pixel[2] << 16); |
|
if (idx > 0 && idx <= nodes.length) return nodes[idx - 1]; |
|
return null; |
|
} |
|
|
|
// === LINEAGE TRAVERSAL === |
|
function getLineage(nodeId) { |
|
const upstream = new Set(), downstream = new Set(); |
|
const upEdges = new Set(), downEdges = new Set(); |
|
|
|
// BFS upstream |
|
const queue = [nodeId]; |
|
upstream.add(nodeId); |
|
while (queue.length) { |
|
const current = queue.shift(); |
|
const n = nodeMap[current]; |
|
if (!n) continue; |
|
for (const edge of edges) { |
|
if (edge.target.id === current && !upstream.has(edge.source.id)) { |
|
upstream.add(edge.source.id); |
|
upEdges.add(edge.id); |
|
queue.push(edge.source.id); |
|
} |
|
} |
|
} |
|
|
|
// BFS downstream |
|
const queue2 = [nodeId]; |
|
downstream.add(nodeId); |
|
while (queue2.length) { |
|
const current = queue2.shift(); |
|
for (const edge of edges) { |
|
if (edge.source.id === current && !downstream.has(edge.target.id)) { |
|
downstream.add(edge.target.id); |
|
downEdges.add(edge.id); |
|
queue2.push(edge.target.id); |
|
} |
|
} |
|
} |
|
|
|
return { |
|
nodes: new Set([...upstream, ...downstream]), |
|
edges: new Set([...upEdges, ...downEdges]), |
|
upstream, downstream |
|
}; |
|
} |
|
|
|
// === MAIN RENDER === |
|
// === STREET GRID (computed from block positions, never crosses through blocks) === |
|
function drawStreetCorridors(ctx) { |
|
const blocks = window._blockPositions; |
|
if (!blocks) return; |
|
|
|
const blockList = Object.values(blocks); |
|
if (blockList.length === 0) return; |
|
|
|
const PAD = 30; // keep roads clear of block edges (accounts for building visual extent) |
|
|
|
// Find overall bounds |
|
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; |
|
for (const bp of blockList) { |
|
minX = Math.min(minX, bp.x); maxX = Math.max(maxX, bp.x + bp.w); |
|
minY = Math.min(minY, bp.y); maxY = Math.max(maxY, bp.y + bp.h); |
|
} |
|
const EXT = 150; |
|
|
|
// Use precomputed street grid positions |
|
const hStreets = streetGridH; |
|
const vStreets = streetGridV; |
|
|
|
// Clip a horizontal line at Y to avoid all blocks |
|
function clipH(y, fromX, toX) { |
|
const obs = []; |
|
for (const bp of blockList) { |
|
if (y >= bp.y - PAD && y <= bp.y + bp.h + PAD) { |
|
obs.push({ min: bp.x - PAD, max: bp.x + bp.w + PAD }); |
|
} |
|
} |
|
obs.sort((a, b) => a.min - b.min); |
|
const segs = []; |
|
let cur = fromX; |
|
for (const o of obs) { |
|
if (cur < o.min) segs.push({ min: cur, max: o.min }); |
|
cur = Math.max(cur, o.max); |
|
} |
|
if (cur < toX) segs.push({ min: cur, max: toX }); |
|
return segs; |
|
} |
|
|
|
// Clip a vertical line at X to avoid all blocks |
|
function clipV(x, fromY, toY) { |
|
const obs = []; |
|
for (const bp of blockList) { |
|
if (x >= bp.x - PAD && x <= bp.x + bp.w + PAD) { |
|
obs.push({ min: bp.y - PAD, max: bp.y + bp.h + PAD }); |
|
} |
|
} |
|
obs.sort((a, b) => a.min - b.min); |
|
const segs = []; |
|
let cur = fromY; |
|
for (const o of obs) { |
|
if (cur < o.min) segs.push({ min: cur, max: o.min }); |
|
cur = Math.max(cur, o.max); |
|
} |
|
if (cur < toY) segs.push({ min: cur, max: toY }); |
|
return segs; |
|
} |
|
|
|
// Draw the street grid |
|
const roadWidth = 8; |
|
|
|
// Asphalt surface |
|
ctx.strokeStyle = '#7a7568'; |
|
ctx.lineWidth = roadWidth; |
|
ctx.lineCap = 'round'; |
|
ctx.globalAlpha = 1; |
|
|
|
const allSegs = []; |
|
for (const y of hStreets) { |
|
for (const s of clipH(y, minX - EXT, maxX + EXT)) { |
|
allSegs.push({ type: 'h', coord: y, min: s.min, max: s.max }); |
|
ctx.beginPath(); |
|
ctx.moveTo(isoX(s.min, y), isoY(s.min, y, 0)); |
|
ctx.lineTo(isoX(s.max, y), isoY(s.max, y, 0)); |
|
ctx.stroke(); |
|
} |
|
} |
|
for (const x of vStreets) { |
|
for (const s of clipV(x, minY - EXT, maxY + EXT)) { |
|
allSegs.push({ type: 'v', coord: x, min: s.min, max: s.max }); |
|
ctx.beginPath(); |
|
ctx.moveTo(isoX(x, s.min), isoY(x, s.min, 0)); |
|
ctx.lineTo(isoX(x, s.max), isoY(x, s.max, 0)); |
|
ctx.stroke(); |
|
} |
|
} |
|
|
|
// Center lane dashes |
|
ctx.strokeStyle = 'rgba(255,222,2,0.15)'; |
|
ctx.lineWidth = 0.8; |
|
ctx.setLineDash([4, 10]); |
|
for (const seg of allSegs) { |
|
ctx.beginPath(); |
|
if (seg.type === 'h') { |
|
ctx.moveTo(isoX(seg.min, seg.coord), isoY(seg.min, seg.coord, 0)); |
|
ctx.lineTo(isoX(seg.max, seg.coord), isoY(seg.max, seg.coord, 0)); |
|
} else { |
|
ctx.moveTo(isoX(seg.coord, seg.min), isoY(seg.coord, seg.min, 0)); |
|
ctx.lineTo(isoX(seg.coord, seg.max), isoY(seg.coord, seg.max, 0)); |
|
} |
|
ctx.stroke(); |
|
} |
|
ctx.setLineDash([]); |
|
} |
|
|
|
function render() { |
|
ctx.clearRect(0, 0, width, height); |
|
|
|
// Sky gradient (blends into sandy terrain at horizon) |
|
const grad = ctx.createLinearGradient(0, 0, 0, height); |
|
grad.addColorStop(0, '#4a80a8'); |
|
grad.addColorStop(0.3, '#6a9ab8'); |
|
grad.addColorStop(0.6, '#8ab0c0'); |
|
grad.addColorStop(1, '#b0c0a8'); |
|
ctx.fillStyle = grad; |
|
ctx.fillRect(0, 0, width, height); |
|
|
|
ctx.save(); |
|
ctx.translate(transform.x, transform.y); |
|
ctx.scale(transform.scale, transform.scale); |
|
|
|
// Ground plane: sandy terrain that fills the entire visible area |
|
// Use a huge size so edges are never visible regardless of zoom/pan |
|
const gSize = 6000; |
|
|
|
// Flat sandy terrain fill (SimCity 2000 style) |
|
ctx.globalAlpha = 1; |
|
ctx.fillStyle = '#c8b888'; |
|
ctx.beginPath(); |
|
ctx.moveTo(isoX(0, -gSize), isoY(0, -gSize, 0)); |
|
ctx.lineTo(isoX(gSize, 0), isoY(gSize, 0, 0)); |
|
ctx.lineTo(isoX(0, gSize), isoY(0, gSize, 0)); |
|
ctx.lineTo(isoX(-gSize, 0), isoY(-gSize, 0, 0)); |
|
ctx.closePath(); |
|
ctx.fill(); |
|
|
|
// SimCity 2000-style terrain diamond grid |
|
ctx.strokeStyle = '#a08850'; |
|
ctx.lineWidth = 0.7; |
|
const terrainStep = 80; |
|
const terrainExtent = 2500; |
|
ctx.globalAlpha = 0.25; |
|
for (let g = -terrainExtent; g <= terrainExtent; g += terrainStep) { |
|
ctx.beginPath(); |
|
ctx.moveTo(isoX(g, -terrainExtent), isoY(g, -terrainExtent, 0)); |
|
ctx.lineTo(isoX(g, terrainExtent), isoY(g, terrainExtent, 0)); |
|
ctx.stroke(); |
|
ctx.beginPath(); |
|
ctx.moveTo(isoX(-terrainExtent, g), isoY(-terrainExtent, g, 0)); |
|
ctx.lineTo(isoX(terrainExtent, g), isoY(terrainExtent, g, 0)); |
|
ctx.stroke(); |
|
} |
|
|
|
ctx.globalAlpha = 1; |
|
|
|
// Street corridors between blocks |
|
drawStreetCorridors(ctx); |
|
|
|
// Edges (roads) |
|
drawEdges(ctx); |
|
|
|
// Schema neighborhoods (on top of roads so borders are visible) |
|
drawSchemaNeighborhoods(ctx); |
|
|
|
// Sort nodes by iso Y (back to front) for correct overlap |
|
const sorted = [...nodes].sort((a, b) => { |
|
const ayIso = isoY(a.x, a.y, 0); |
|
const byIso = isoY(b.x, b.y, 0); |
|
return ayIso - byIso; |
|
}); |
|
|
|
// Draw buildings |
|
for (const node of sorted) { |
|
let alpha = 1; |
|
if (highlightedNodes.size > 0 && !highlightedNodes.has(node.id)) { |
|
alpha = 0.12; |
|
} |
|
if (searchTerm && !node.name.toLowerCase().includes(searchTerm)) { |
|
alpha = Math.min(alpha, 0.12); |
|
} else if (searchTerm && node.name.toLowerCase().includes(searchTerm)) { |
|
alpha = 1; |
|
} |
|
|
|
drawNode(ctx, node, alpha); |
|
|
|
// Glow for selected |
|
if (selectedNode && node.id === selectedNode.id) { |
|
const sx = isoX(node.x, node.y); |
|
const sy = isoY(node.x, node.y, 0) - buildingHeight(node) / 2; |
|
ctx.shadowColor = COLORS.sun; |
|
ctx.shadowBlur = 15; |
|
ctx.fillStyle = 'rgba(255,222,2,0.01)'; |
|
ctx.fillRect(sx - 1, sy - 1, 2, 2); |
|
ctx.shadowBlur = 0; |
|
} |
|
} |
|
|
|
// Labels for major buildings (top 12 most connected) |
|
const labeled = [...nodes] |
|
.filter(n => n.type === 'model' || n.type === 'source') |
|
.sort((a, b) => (b.childCount + b.parentCount) - (a.childCount + a.parentCount)) |
|
.slice(0, 12); |
|
for (const node of labeled) { |
|
let alpha = 0.7; |
|
if (highlightedNodes.size > 0 && !highlightedNodes.has(node.id)) alpha = 0.08; |
|
if (searchTerm && !node.name.toLowerCase().includes(searchTerm)) alpha = 0.08; |
|
|
|
const sx = isoX(node.x, node.y); |
|
const sy = isoY(node.x, node.y, 0) - buildingHeight(node) - 8; |
|
ctx.globalAlpha = alpha; |
|
ctx.fillStyle = '#fff'; |
|
ctx.font = '9px system-ui'; |
|
ctx.textAlign = 'center'; |
|
// Truncate long names |
|
const label = node.name.length > 25 ? node.name.slice(0, 22) + '...' : node.name; |
|
ctx.fillText(label, sx, sy); |
|
} |
|
ctx.globalAlpha = 1; |
|
|
|
ctx.restore(); |
|
|
|
// Atmospheric haze: subtle fade at edges |
|
const fogGrad = ctx.createRadialGradient(width/2, height/2, Math.min(width, height) * 0.3, width/2, height/2, Math.max(width, height) * 0.75); |
|
fogGrad.addColorStop(0, 'rgba(180,192,168,0)'); |
|
fogGrad.addColorStop(0.7, 'rgba(180,192,168,0)'); |
|
fogGrad.addColorStop(1, 'rgba(180,192,168,0.25)'); |
|
ctx.fillStyle = fogGrad; |
|
ctx.fillRect(0, 0, width, height); |
|
|
|
renderPickCanvas(); |
|
} |
|
|
|
// === INTERACTION === |
|
function setupInteraction() { |
|
let isDragging = false, lastX, lastY; |
|
|
|
canvas.addEventListener('mousedown', (e) => { |
|
isDragging = true; |
|
lastX = e.clientX; |
|
lastY = e.clientY; |
|
}); |
|
|
|
window.addEventListener('mousemove', (e) => { |
|
if (isDragging) { |
|
transform.x += e.clientX - lastX; |
|
transform.y += e.clientY - lastY; |
|
lastX = e.clientX; |
|
lastY = e.clientY; |
|
render(); |
|
return; |
|
} |
|
|
|
const node = getNodeAtPixel(e.clientX, e.clientY); |
|
if (node !== hoveredNode) { |
|
hoveredNode = node; |
|
if (node) { |
|
showTooltip(e.clientX, e.clientY, node); |
|
} else { |
|
hideTooltip(); |
|
} |
|
canvas.style.cursor = node ? 'pointer' : 'grab'; |
|
} else if (node) { |
|
positionTooltip(e.clientX, e.clientY); |
|
} |
|
}); |
|
|
|
window.addEventListener('mouseup', () => { isDragging = false; }); |
|
|
|
canvas.addEventListener('click', (e) => { |
|
const node = getNodeAtPixel(e.clientX, e.clientY); |
|
if (node) { |
|
selectNode(node); |
|
} else { |
|
clearSelection(); |
|
} |
|
}); |
|
|
|
canvas.addEventListener('wheel', (e) => { |
|
e.preventDefault(); |
|
const delta = e.deltaY > 0 ? 0.9 : 1.1; |
|
const mx = e.clientX, my = e.clientY; |
|
|
|
// Zoom toward cursor |
|
transform.x = mx - (mx - transform.x) * delta; |
|
transform.y = my - (my - transform.y) * delta; |
|
transform.scale *= delta; |
|
transform.scale = Math.max(0.2, Math.min(5, transform.scale)); |
|
render(); |
|
}, { passive: false }); |
|
|
|
// Search |
|
document.getElementById('search-box').addEventListener('input', (e) => { |
|
searchTerm = e.target.value.toLowerCase(); |
|
render(); |
|
}); |
|
|
|
// Keyboard controls: WASD / arrow keys for panning, +/-/Q/E for zoom |
|
const keysDown = new Set(); |
|
const PAN_SPEED = 8; |
|
const ZOOM_SPEED = 0.02; |
|
let animFrame = null; |
|
|
|
function gameLoop() { |
|
let moved = false; |
|
if (keysDown.has('ArrowLeft') || keysDown.has('a')) { transform.x += PAN_SPEED; moved = true; } |
|
if (keysDown.has('ArrowRight') || keysDown.has('d')) { transform.x -= PAN_SPEED; moved = true; } |
|
if (keysDown.has('ArrowUp') || keysDown.has('w')) { transform.y += PAN_SPEED; moved = true; } |
|
if (keysDown.has('ArrowDown') || keysDown.has('s')) { transform.y -= PAN_SPEED; moved = true; } |
|
if (keysDown.has('e') || keysDown.has('=') || keysDown.has('+')) { |
|
const cx = width / 2, cy = height / 2; |
|
const factor = 1 + ZOOM_SPEED; |
|
transform.x = cx - (cx - transform.x) * factor; |
|
transform.y = cy - (cy - transform.y) * factor; |
|
transform.scale = Math.min(5, transform.scale * factor); |
|
moved = true; |
|
} |
|
if (keysDown.has('q') || keysDown.has('-')) { |
|
const cx = width / 2, cy = height / 2; |
|
const factor = 1 - ZOOM_SPEED; |
|
transform.x = cx - (cx - transform.x) * factor; |
|
transform.y = cy - (cy - transform.y) * factor; |
|
transform.scale = Math.max(0.2, transform.scale * factor); |
|
moved = true; |
|
} |
|
if (moved) render(); |
|
if (keysDown.size > 0) animFrame = requestAnimationFrame(gameLoop); |
|
else animFrame = null; |
|
} |
|
|
|
window.addEventListener('keydown', (e) => { |
|
// Don't capture when typing in search |
|
if (e.target.tagName === 'INPUT') return; |
|
const key = e.key; |
|
if (['ArrowLeft','ArrowRight','ArrowUp','ArrowDown','w','a','s','d','q','e','+','-','='].includes(key)) { |
|
e.preventDefault(); |
|
keysDown.add(key); |
|
if (!animFrame) animFrame = requestAnimationFrame(gameLoop); |
|
} |
|
// Escape to clear selection |
|
if (key === 'Escape') clearSelection(); |
|
// Space to reset view |
|
if (key === ' ') { |
|
e.preventDefault(); |
|
transform.x = width / 2; |
|
transform.y = height / 2; |
|
transform.scale = 2; |
|
render(); |
|
} |
|
}); |
|
|
|
window.addEventListener('keyup', (e) => { |
|
keysDown.delete(e.key); |
|
}); |
|
} |
|
|
|
function showTooltip(mx, my, node) { |
|
const tt = document.getElementById('tooltip'); |
|
const badge = TYPE_BADGES[node.type]; |
|
tt.innerHTML = ` |
|
<div class="tt-name">${node.name} |
|
<span class="tt-badge" style="background:${badge.bg}">${badge.label}</span> |
|
</div> |
|
<div class="tt-schema">${node.schema}${node.sourceName ? ' / ' + node.sourceName : ''}</div> |
|
${node.description ? `<div class="tt-desc">${node.description}</div>` : ''} |
|
<div class="tt-stats">${node.parentCount} upstream · ${node.childCount} downstream</div> |
|
`; |
|
tt.style.display = 'block'; |
|
positionTooltip(mx, my); |
|
} |
|
|
|
function positionTooltip(mx, my) { |
|
const tt = document.getElementById('tooltip'); |
|
const pad = 15; |
|
let x = mx + pad, y = my + pad; |
|
if (x + 320 > width) x = mx - 320 - pad; |
|
if (y + 150 > height) y = my - 150; |
|
tt.style.left = x + 'px'; |
|
tt.style.top = y + 'px'; |
|
} |
|
|
|
function hideTooltip() { |
|
document.getElementById('tooltip').style.display = 'none'; |
|
} |
|
|
|
function selectNode(node) { |
|
selectedNode = node; |
|
const lineage = getLineage(node.id); |
|
highlightedNodes = lineage.nodes; |
|
highlightedEdges = lineage.edges; |
|
|
|
// Show info panel |
|
const panel = document.getElementById('info-panel'); |
|
const upList = [...lineage.upstream].filter(id => id !== node.id).map(id => nodeMap[id]).filter(Boolean); |
|
const downList = [...lineage.downstream].filter(id => id !== node.id).map(id => nodeMap[id]).filter(Boolean); |
|
|
|
document.getElementById('info-content').innerHTML = ` |
|
<h3>${node.name}</h3> |
|
<p style="color:#aaa; margin:4px 0 8px">${node.type} / ${node.schema}</p> |
|
${node.description ? `<p style="color:#ccc; margin-bottom:8px; line-height:1.4">${node.description}</p>` : ''} |
|
<div style="margin-top:8px"> |
|
<strong style="color:${COLORS.sky}">Upstream (${upList.length})</strong> |
|
<div class="lineage-list">${upList.map(n => `<div onclick="focusNode('${n.id}')">${n.name}</div>`).join('')}</div> |
|
</div> |
|
<div style="margin-top:8px"> |
|
<strong style="color:${COLORS.sun}">Downstream (${downList.length})</strong> |
|
<div class="lineage-list">${downList.map(n => `<div onclick="focusNode('${n.id}')">${n.name}</div>`).join('')}</div> |
|
</div> |
|
`; |
|
panel.style.display = 'block'; |
|
render(); |
|
} |
|
|
|
function clearSelection() { |
|
selectedNode = null; |
|
highlightedNodes = new Set(); |
|
highlightedEdges = new Set(); |
|
document.getElementById('info-panel').style.display = 'none'; |
|
render(); |
|
} |
|
|
|
function focusNode(id) { |
|
const node = nodeMap[id]; |
|
if (!node) return; |
|
const sx = isoX(node.x, node.y) * transform.scale + transform.x; |
|
const sy = isoY(node.x, node.y, 0) * transform.scale + transform.y; |
|
transform.x += width / 2 - sx; |
|
transform.y += height / 2 - sy; |
|
selectNode(node); |
|
} |
|
|
|
// === LAYOUT ENGINE (Neighborhood blocks) === |
|
let roadPaths = null; |
|
let bundledRoads = null; // merged corridor segments for rendering |
|
|
|
function runLayout(data, callback) { |
|
const status = document.getElementById('load-status'); |
|
status.textContent = 'Surveying land...'; |
|
|
|
// Create nodes |
|
nodes = data.nodes.map(n => ({ ...n })); |
|
nodeMap = {}; |
|
nodes.forEach(n => { nodeMap[n.id] = n; }); |
|
|
|
// Create edges |
|
let edgeId = 0; |
|
edges = data.edges |
|
.filter(([s, t]) => nodeMap[s] && nodeMap[t]) |
|
.map(([s, t]) => ({ id: edgeId++, source: nodeMap[s], target: nodeMap[t] })); |
|
|
|
// Group by schema |
|
schemaGroups = {}; |
|
nodes.forEach(n => { |
|
const key = n.schema || 'other'; |
|
if (!schemaGroups[key]) schemaGroups[key] = []; |
|
schemaGroups[key].push(n); |
|
}); |
|
|
|
status.textContent = 'Zoning districts...'; |
|
|
|
// --- Topological depth (used for ordering within blocks) --- |
|
const childAdj = {}, parentAdj = {}; |
|
nodes.forEach(n => { childAdj[n.id] = []; parentAdj[n.id] = []; }); |
|
edges.forEach(e => { |
|
childAdj[e.source.id].push(e.target.id); |
|
parentAdj[e.target.id].push(e.source.id); |
|
}); |
|
|
|
const depth = {}; |
|
nodes.forEach(n => { depth[n.id] = 0; }); |
|
const inDeg = {}; |
|
nodes.forEach(n => { inDeg[n.id] = parentAdj[n.id].length; }); |
|
const topoQueue = nodes.filter(n => inDeg[n.id] === 0).map(n => n.id); |
|
let ti = 0; |
|
while (ti < topoQueue.length) { |
|
const id = topoQueue[ti++]; |
|
for (const cid of childAdj[id]) { |
|
depth[cid] = Math.max(depth[cid], depth[id] + 1); |
|
inDeg[cid]--; |
|
if (inDeg[cid] === 0) topoQueue.push(cid); |
|
} |
|
} |
|
|
|
// --- Build neighborhood blocks --- |
|
// Each schema is a "block": a rectangular grid of buildings. |
|
// Blocks are arranged in a 2D city grid with streets between them. |
|
|
|
const CELL = 80; // spacing within a block (room for roads between buildings) |
|
const STREET = 140; // gap between blocks (wide avenue between neighborhoods) |
|
|
|
// Sort schemas: by median depth (left-to-right flow) then by size |
|
const schemaKeys = Object.keys(schemaGroups); |
|
const schemaMedianDepth = {}; |
|
schemaKeys.forEach(s => { |
|
const depths = schemaGroups[s].map(n => depth[n.id]).sort((a, b) => a - b); |
|
schemaMedianDepth[s] = depths[Math.floor(depths.length / 2)]; |
|
}); |
|
schemaKeys.sort((a, b) => { |
|
const da = schemaMedianDepth[a], db = schemaMedianDepth[b]; |
|
if (da !== db) return da - db; |
|
return schemaGroups[b].length - schemaGroups[a].length; |
|
}); |
|
|
|
// Within each schema block, sort nodes by depth then connectivity |
|
schemaKeys.forEach(s => { |
|
schemaGroups[s].sort((a, b) => { |
|
if (depth[a.id] !== depth[b.id]) return depth[a.id] - depth[b.id]; |
|
return (b.childCount + b.parentCount) - (a.childCount + a.parentCount); |
|
}); |
|
}); |
|
|
|
// Arrange blocks in a roughly square grid. |
|
// Use a simple packing: place blocks left-to-right, wrapping to a new row |
|
// when width exceeds a target. Target width: sqrt of total area. |
|
const blockSizes = schemaKeys.map(s => { |
|
const count = schemaGroups[s].length; |
|
const cols = Math.ceil(Math.sqrt(count * 1.5)); // slightly wider than tall |
|
const rows = Math.ceil(count / cols); |
|
return { schema: s, cols, rows, w: cols * CELL, h: rows * CELL }; |
|
}); |
|
|
|
// Target total width: aim for roughly square city |
|
const totalArea = blockSizes.reduce((sum, b) => sum + (b.w + STREET) * (b.h + STREET), 0); |
|
const targetW = Math.sqrt(totalArea) * 1.2; |
|
|
|
// Place blocks row by row |
|
let curX = 0, curY = 0, rowH = 0; |
|
const blockPositions = {}; |
|
|
|
for (const block of blockSizes) { |
|
if (curX > 0 && curX + block.w > targetW) { |
|
// Wrap to next row |
|
curY += rowH + STREET; |
|
curX = 0; |
|
rowH = 0; |
|
} |
|
blockPositions[block.schema] = { x: curX, y: curY, w: block.w, h: block.h, cols: block.cols }; |
|
curX += block.w + STREET; |
|
rowH = Math.max(rowH, block.h); |
|
} |
|
|
|
status.textContent = 'Building blocks...'; |
|
|
|
// Place nodes within their block |
|
for (const s of schemaKeys) { |
|
const block = blockPositions[s]; |
|
const group = schemaGroups[s]; |
|
group.forEach((node, i) => { |
|
const col = i % block.cols; |
|
const row = Math.floor(i / block.cols); |
|
node.x = block.x + col * CELL; |
|
node.y = block.y + row * CELL; |
|
}); |
|
} |
|
|
|
// Center around origin |
|
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity; |
|
nodes.forEach(n => { |
|
minX = Math.min(minX, n.x); maxX = Math.max(maxX, n.x); |
|
minY = Math.min(minY, n.y); maxY = Math.max(maxY, n.y); |
|
}); |
|
const cx = (minX + maxX) / 2, cy = (minY + maxY) / 2; |
|
nodes.forEach(n => { n.x -= cx; n.y -= cy; }); |
|
|
|
// Store block positions for neighborhood rendering (adjust for centering) |
|
for (const s of schemaKeys) { |
|
const bp = blockPositions[s]; |
|
bp.x -= cx; bp.y -= cy; |
|
} |
|
// Expose for schema hull drawing |
|
window._blockPositions = blockPositions; |
|
|
|
status.textContent = 'Laying streets...'; |
|
|
|
// --- Compute street grid and road paths --- |
|
computeStreetGrid(); |
|
computeRoadPaths(); |
|
|
|
status.textContent = 'Opening for business...'; |
|
requestAnimationFrame(callback); |
|
} |
|
|
|
function computeStreetGrid() { |
|
const blocks = window._blockPositions; |
|
if (!blocks) return; |
|
const blockList = Object.values(blocks); |
|
const HALF_STREET = 70; |
|
const hRaw = new Set(), vRaw = new Set(); |
|
for (const bp of blockList) { |
|
hRaw.add(Math.round(bp.y - HALF_STREET)); |
|
hRaw.add(Math.round(bp.y + bp.h + HALF_STREET)); |
|
vRaw.add(Math.round(bp.x - HALF_STREET)); |
|
vRaw.add(Math.round(bp.x + bp.w + HALF_STREET)); |
|
} |
|
function dedup(set, threshold) { |
|
const sorted = [...set].sort((a, b) => a - b); |
|
const result = []; |
|
for (const v of sorted) { |
|
if (result.length === 0 || v - result[result.length - 1] > threshold) { |
|
result.push(v); |
|
} else { |
|
result[result.length - 1] = (result[result.length - 1] + v) / 2; |
|
} |
|
} |
|
return result; |
|
} |
|
streetGridH = dedup(hRaw, 80); |
|
streetGridV = dedup(vRaw, 80); |
|
} |
|
|
|
function nearestStreet(val, grid) { |
|
let best = grid[0], bestDist = Math.abs(val - best); |
|
for (const s of grid) { |
|
const d = Math.abs(val - s); |
|
if (d < bestDist) { best = s; bestDist = d; } |
|
} |
|
return best; |
|
} |
|
|
|
function computeRoadPaths() { |
|
const blocks = window._blockPositions; |
|
// ROAD_OFF: offset roads from building centers so they run in the lane between buildings |
|
const ROAD_OFF = 30; // half of CELL spacing, puts road midway between two buildings |
|
|
|
// For each node, compute an "exit point" at the nearest edge of its block |
|
function exitPoint(node, towardX, towardY) { |
|
const bp = blocks && blocks[node.schema || 'other']; |
|
if (!bp) return { x: node.x, y: node.y }; |
|
|
|
const nx = node.x, ny = node.y; |
|
// Block edges |
|
const left = bp.x - ROAD_OFF; |
|
const right = bp.x + bp.w + ROAD_OFF; |
|
const top = bp.y - ROAD_OFF; |
|
const bottom = bp.y + bp.h + ROAD_OFF; |
|
|
|
// Decide which edge to exit from: pick the edge closest to the target direction |
|
const dx = towardX - nx, dy = towardY - ny; |
|
|
|
if (Math.abs(dx) >= Math.abs(dy)) { |
|
// Exit left or right |
|
return dx > 0 |
|
? { x: right, y: ny } |
|
: { x: left, y: ny }; |
|
} else { |
|
// Exit top or bottom |
|
return dy > 0 |
|
? { x: nx, y: bottom } |
|
: { x: nx, y: top }; |
|
} |
|
} |
|
|
|
roadPaths = edges.map((edge) => { |
|
const sx = edge.source.x, sy = edge.source.y; |
|
const tx = edge.target.x, ty = edge.target.y; |
|
const dx = tx - sx, dy = ty - sy; |
|
|
|
const sameSchema = edge.source.schema === edge.target.schema; |
|
const dist = Math.sqrt(dx * dx + dy * dy); |
|
|
|
// Within same block: only draw the lane segments between buildings, |
|
// NOT the connector from building center to lane (that goes through buildings) |
|
if (sameSchema) { |
|
if (dist < 85) { |
|
// Adjacent buildings: no visible road needed |
|
return { edgeId: edge.id, points: [] }; |
|
} |
|
// L-shape: only the shared lane segment (the middle piece) |
|
if (Math.abs(dx) >= Math.abs(dy)) { |
|
const laneY = sy + (dy > 0 ? ROAD_OFF : -ROAD_OFF); |
|
return { edgeId: edge.id, points: [ |
|
{ x: sx, y: laneY }, |
|
{ x: tx, y: laneY } |
|
]}; |
|
} else { |
|
const laneX = sx + (dx > 0 ? ROAD_OFF : -ROAD_OFF); |
|
return { edgeId: edge.id, points: [ |
|
{ x: laneX, y: sy }, |
|
{ x: laneX, y: ty } |
|
]}; |
|
} |
|
} |
|
|
|
// Cross-block: route along the street grid |
|
const srcExit = exitPoint(edge.source, tx, ty); |
|
const tgtEntry = exitPoint(edge.target, sx, sy); |
|
|
|
// Snap exit/entry to nearest streets so paths follow the grid |
|
const sStreetY = streetGridH.length ? nearestStreet(srcExit.y, streetGridH) : srcExit.y; |
|
const sStreetX = streetGridV.length ? nearestStreet(srcExit.x, streetGridV) : srcExit.x; |
|
const tStreetY = streetGridH.length ? nearestStreet(tgtEntry.y, streetGridH) : tgtEntry.y; |
|
const tStreetX = streetGridV.length ? nearestStreet(tgtEntry.x, streetGridV) : tgtEntry.x; |
|
|
|
if (Math.abs(srcExit.x - tgtEntry.x) < 20) { |
|
// Vertically aligned: snap x, route along vertical street |
|
const streetX = nearestStreet((srcExit.x + tgtEntry.x) / 2, streetGridV.length ? streetGridV : [srcExit.x]); |
|
return { edgeId: edge.id, points: [ |
|
{ x: streetX, y: sStreetY }, |
|
{ x: streetX, y: tStreetY } |
|
]}; |
|
} |
|
|
|
if (Math.abs(srcExit.y - tgtEntry.y) < 20) { |
|
// Horizontally aligned: snap y, route along horizontal street |
|
const streetY = nearestStreet((srcExit.y + tgtEntry.y) / 2, streetGridH.length ? streetGridH : [srcExit.y]); |
|
return { edgeId: edge.id, points: [ |
|
{ x: sStreetX, y: streetY }, |
|
{ x: tStreetX, y: streetY } |
|
]}; |
|
} |
|
|
|
// General case: Z-shape along streets |
|
// Pick the channel that runs along a real street |
|
const channelX = nearestStreet((srcExit.x + tgtEntry.x) / 2, streetGridV.length ? streetGridV : [(srcExit.x + tgtEntry.x) / 2]); |
|
const channelY = nearestStreet((srcExit.y + tgtEntry.y) / 2, streetGridH.length ? streetGridH : [(srcExit.y + tgtEntry.y) / 2]); |
|
|
|
if (Math.abs(dx) >= Math.abs(dy)) { |
|
// Route: horizontal on sStreetY → vertical on channelX → horizontal on tStreetY |
|
return { edgeId: edge.id, points: [ |
|
{ x: sStreetX, y: sStreetY }, |
|
{ x: channelX, y: sStreetY }, |
|
{ x: channelX, y: tStreetY }, |
|
{ x: tStreetX, y: tStreetY } |
|
]}; |
|
} else { |
|
// Route: vertical on sStreetX → horizontal on channelY → vertical on tStreetX |
|
return { edgeId: edge.id, points: [ |
|
{ x: sStreetX, y: sStreetY }, |
|
{ x: sStreetX, y: channelY }, |
|
{ x: tStreetX, y: channelY }, |
|
{ x: tStreetX, y: tStreetY } |
|
]}; |
|
} |
|
}); |
|
|
|
// --- Bundle overlapping segments into corridors --- |
|
computeBundledRoads(); |
|
} |
|
|
|
function computeBundledRoads() { |
|
const SNAP = 12; // quantize grid for merging nearby parallel segments |
|
|
|
// Decompose all road paths into axis-aligned segments |
|
const hSegs = {}; // snappedY -> [{min, max, edgeId}] |
|
const vSegs = {}; // snappedX -> [{min, max, edgeId}] |
|
|
|
for (const road of roadPaths) { |
|
const pts = road.points; |
|
for (let i = 0; i < pts.length - 1; i++) { |
|
const p1 = pts[i], p2 = pts[i + 1]; |
|
const dx = Math.abs(p2.x - p1.x); |
|
const dy = Math.abs(p2.y - p1.y); |
|
if (dx < 2 && dy < 2) continue; |
|
|
|
if (dy <= dx) { |
|
// Horizontal segment |
|
const snapY = Math.round(((p1.y + p2.y) / 2) / SNAP) * SNAP; |
|
if (!hSegs[snapY]) hSegs[snapY] = []; |
|
hSegs[snapY].push({ min: Math.min(p1.x, p2.x), max: Math.max(p1.x, p2.x), edgeId: road.edgeId }); |
|
} else { |
|
// Vertical segment |
|
const snapX = Math.round(((p1.x + p2.x) / 2) / SNAP) * SNAP; |
|
if (!vSegs[snapX]) vSegs[snapX] = []; |
|
vSegs[snapX].push({ min: Math.min(p1.y, p2.y), max: Math.max(p1.y, p2.y), edgeId: road.edgeId }); |
|
} |
|
} |
|
} |
|
|
|
// Merge overlapping segments within each corridor |
|
function mergeCorridorSegments(segs) { |
|
segs.sort((a, b) => a.min - b.min); |
|
const merged = []; |
|
let cur = { min: segs[0].min, max: segs[0].max, count: 1, edgeIds: new Set([segs[0].edgeId]) }; |
|
|
|
for (let i = 1; i < segs.length; i++) { |
|
// Segments overlap or are very close |
|
if (segs[i].min <= cur.max + SNAP) { |
|
cur.max = Math.max(cur.max, segs[i].max); |
|
cur.count++; |
|
cur.edgeIds.add(segs[i].edgeId); |
|
} else { |
|
merged.push(cur); |
|
cur = { min: segs[i].min, max: segs[i].max, count: 1, edgeIds: new Set([segs[i].edgeId]) }; |
|
} |
|
} |
|
merged.push(cur); |
|
return merged; |
|
} |
|
|
|
bundledRoads = []; |
|
|
|
for (const [snapY, segs] of Object.entries(hSegs)) { |
|
for (const m of mergeCorridorSegments(segs)) { |
|
bundledRoads.push({ |
|
type: 'h', coord: parseFloat(snapY), |
|
min: m.min, max: m.max, |
|
count: m.count, edgeIds: m.edgeIds |
|
}); |
|
} |
|
} |
|
|
|
for (const [snapX, segs] of Object.entries(vSegs)) { |
|
for (const m of mergeCorridorSegments(segs)) { |
|
bundledRoads.push({ |
|
type: 'v', coord: parseFloat(snapX), |
|
min: m.min, max: m.max, |
|
count: m.count, edgeIds: m.edgeIds |
|
}); |
|
} |
|
} |
|
} |
|
|
|
// === INITIALIZATION === |
|
async function init() { |
|
canvas = document.getElementById('city'); |
|
ctx = canvas.getContext('2d'); |
|
|
|
width = window.innerWidth; |
|
height = window.innerHeight; |
|
canvas.width = width; |
|
canvas.height = height; |
|
|
|
pickCanvas = document.createElement('canvas'); |
|
pickCanvas.width = width; |
|
pickCanvas.height = height; |
|
pickCtx = pickCanvas.getContext('2d', { willReadFrequently: true }); |
|
|
|
// Load data |
|
const status = document.getElementById('load-status'); |
|
status.textContent = 'Loading manifest...'; |
|
|
|
let data; |
|
if (typeof EMBEDDED_DATA !== 'undefined') { |
|
data = EMBEDDED_DATA; |
|
} else { |
|
// Try fetching the extracted data |
|
try { |
|
const resp = await fetch('graph_data.json'); |
|
data = await resp.json(); |
|
} catch(e) { |
|
status.textContent = 'Error: could not load graph data'; |
|
return; |
|
} |
|
} |
|
|
|
DATA = data; |
|
|
|
// Center the view |
|
transform.x = width / 2; |
|
transform.y = height / 2; |
|
transform.scale = 2; |
|
|
|
runLayout(data, () => { |
|
document.getElementById('loading').style.display = 'none'; |
|
|
|
// Show stats |
|
const counts = { model: 0, source: 0, seed: 0, snapshot: 0 }; |
|
nodes.forEach(n => { if (counts[n.type] !== undefined) counts[n.type]++; }); |
|
document.getElementById('stats').textContent = |
|
`${counts.model} models \u00b7 ${counts.source} sources \u00b7 ${counts.seed} seeds \u00b7 ${counts.snapshot} snapshots \u00b7 ${edges.length} connections`; |
|
|
|
setupInteraction(); |
|
setupTexturePanel(); |
|
render(); |
|
}); |
|
|
|
window.addEventListener('resize', () => { |
|
width = window.innerWidth; |
|
height = window.innerHeight; |
|
canvas.width = width; |
|
canvas.height = height; |
|
pickCanvas.width = width; |
|
pickCanvas.height = height; |
|
render(); |
|
}); |
|
} |
|
|
|
init(); |
|
</script> |
|
</body> |
|
</html> |