Skip to content

Instantly share code, notes, and snippets.

@dumkydewilde
Last active March 29, 2026 17:42
Show Gist options
  • Select an option

  • Save dumkydewilde/3592ff6c6555347688b758e54ada7931 to your computer and use it in GitHub Desktop.

Select an option

Save dumkydewilde/3592ff6c6555347688b758e54ada7931 to your computer and use it in GitHub Desktop.
dbt City: SimCity-style isometric dbt lineage visualization (single HTML file)

dbt City: SimCity-style dbt Lineage Viewer

dbt City renders your dbt project's DAG as an isometric SimCity-style city. Sources become factories, models become office buildings (sized by connectivity), seeds become parks, and snapshots become warehouses. Schema groups form neighborhoods connected by a street grid that represents data lineage.

dbt City

Quick Start

Open index.html in any modern browser. That is it: no server, no dependencies, no build step. The included example uses the classic jaffle_shop project.

Controls

Action Input
Pan Click + drag, or WASD / arrow keys
Zoom Scroll wheel, or Q/E / +/- keys
Select node Click a building
Clear selection Click empty space or press Escape
Reset view Press Space
Search Type in the search box (top bar)
Toggle sprites Use the "Textures" checkbox (bottom right)

Clicking a building highlights its full upstream and downstream lineage.

Using Your Own dbt Project

  1. Generate (or locate) your dbt manifest.json:
dbt compile   # or dbt run, dbt build, etc.
# manifest.json is at target/manifest.json
  1. Extract the graph data:
python extract_graph.py target/manifest.json > graph_data.json
  1. Open index.html and replace the EMBEDDED_DATA variable at the top of the first <script> block with the contents of your graph_data.json:
const EMBEDDED_DATA = { /* paste your graph_data.json here */ };

Alternatively, remove the EMBEDDED_DATA line entirely and place graph_data.json next to index.html, then serve with any local HTTP server (the file will be fetched automatically):

python -m http.server 8000

graph_data.json Format

{
  "nodes": [
    {
      "id": "model.my_project.my_model",
      "name": "my_model",
      "type": "model",
      "schema": "analytics",
      "description": "...",
      "childCount": 3,
      "parentCount": 2
    }
  ],
  "edges": [
    ["source.my_project.raw.raw_table", "model.my_project.stg_table"]
  ]
}

Node types: model, source, seed, snapshot. The schema field determines neighborhood grouping.

"""Extract graph data from a dbt manifest for the SimCity visualization."""
import json
import sys
def extract(manifest_path: str) -> dict:
with open(manifest_path) as f:
m = json.load(f)
nodes = []
node_ids = set()
# Process models, seeds, snapshots
for uid, node in m["nodes"].items():
rtype = node.get("resource_type")
if rtype not in ("model", "seed", "snapshot"):
continue
children = [c for c in m.get("child_map", {}).get(uid, []) if not c.startswith("test.")]
parents = [p for p in m.get("parent_map", {}).get(uid, []) if not p.startswith("test.")]
nodes.append({
"id": uid,
"name": node["name"],
"type": rtype,
"schema": node.get("schema", ""),
"description": (node.get("description") or "")[:200],
"childCount": len(children),
"parentCount": len(parents),
})
node_ids.add(uid)
# Process sources
for uid, src in m.get("sources", {}).items():
children = [c for c in m.get("child_map", {}).get(uid, []) if not c.startswith("test.")]
nodes.append({
"id": uid,
"name": src["name"],
"type": "source",
"schema": src.get("schema", ""),
"sourceName": src.get("source_name", ""),
"description": (src.get("description") or "")[:200],
"childCount": len(children),
"parentCount": 0,
})
node_ids.add(uid)
# Extract edges from child_map (source -> target)
edges = []
seen = set()
for uid, children in m.get("child_map", {}).items():
if uid not in node_ids:
continue
for child in children:
if child in node_ids and (uid, child) not in seen:
edges.append([uid, child])
seen.add((uid, child))
return {"nodes": nodes, "edges": edges}
if __name__ == "__main__":
path = sys.argv[1] if len(sys.argv) > 1 else ".context/attachments/example_manifest.json"
data = extract(path)
print(json.dumps(data))
<!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()">&times;</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,iVBORw0KGgoAAAANSUhEUgAAAFAAAABwCAYAAACJvfebAAAQAElEQVR4AdycD9AeRX3Hf+9DO1MgUKhVmwRCWpJoiE3S2hhNR1KIthhJoi0TG621hWYEHQrEsZYZcUbaKSgjfwSJiNAi09pGKjVgrISARGsJAYQU5lVIaYgkOEOR8C+WMvK4n73bu9273b2957nnfRMz973d/e1vf7v7ub27fR543p4cVP/GDrjRxgEecOPtHwwALWqN47V8D7ip+QfU9Yg9K7CRmjWyNN+uB20NoHU2bcTpYT0APY1bEag7txp0vblnQKWppXvZsKNcCTA2klYEWjnXp+Fp3tnQ6r35LbEOKy1KgJ6BV3wnrTjhQ2vRYQnQwtPiAlitfNnuIvmi27bV7z+rb2vt2ee3wGBHyvKpI/cCHKpn+i96zyMVZSpdRapKx4gToBa++eT+I4/+QPv/8lFHCaJAHalfkaCqQT5ylYsfXoDxJgm11d6rZStEpKr0CjgBbvv2baWfyj27b586Zwf5MMRA0Kxp8nlAgPGrl9z7kI4P3H2HMxBWoVl9hDb5MES8PHKiVuvdygKga642qpa7uXrVqHbZfp6Rt+ti+T1P7BXE6sOPFK1+/5npg456upUFQNdM15MkdSW5Nen9hRf2k+hnmm8VLVm2sn/Y4VNkzuzXaT9Oe5/8oSBWI+Cwkd9w4+dVZErS6aknIwk7+BiXnJxBIcL0Y6aRCCDQ6vxNq43qdMy06YJU1nsAjorqrY7NaKDpW4160mLpWe1M/52my9/9AT2a/S++oOMCTWesE6syA3mmfvPynEOWi5M1EB2jVdAdWmU3G5ix1ajnNoiXrHZxxwFqjzt+fv/hHd/TLadNPVanT+zdo1P7NGXKYXlxTPC7bdPX5dorLxpjle3ZvSuv6yppnrECGKDc1RgS4rDyps+cKYuX/K6+Jfc994z44BEKOyIPTNqwIikfdugUqULkGUldUENOXwFsphzsvIMKXgQA41nG7UlI8oi8T9U6c5s+/t87NA4DEXjf3bJR23xxtM07/XgT3S4/KYB5bqAkvaNQ+D27dulVR/2UKeb2pCRy1JFHZxnr7IP3zFP/W3g8tfexMcQt3QivaFVmshl5qZZOVm5IgDLUS5xb7+hX/6qE/gHU3K4hH+xm5SUPJqNE05rS0WVNhwTYb/MSz3q0zubWM7euVVVkWXEhiLSz94BjqbNP9StGEc4MCbASOHJlK566yLMKAKw0bfCctn33P8Rsa2yQJk+9aRbj0nJoJqRK4y27BWjPIN6v8GnjpGV/oAaYdhhQgEM8H/nEUdy+tTDuAOyh1VyjhnjLbgHaA4n3qz25hW1pozrtyfd/gOIZyQth/09ekEfHHyrEnhG7cg8cCQOwW7q87ZpoviXAAXuJDqGsBCYlpm7yBhJvVlvelTfM8OiUzluqJcABe0kcFM9EwLH9ABxp1jTxPNrheQfREqA3RmvjdzZ+tTZVNr2sMMD5Aw6zvPwRu7BOCkAGvmLVuwVorDr0jav+CnNENeYR34mrKgFO4AX+7Fc2y58sOU7+/hN/ITu3fElr8389nTbrCRxnyoBKgBN4gdkAn3f5vwoCHEoZrPZJGmci5UQ33W/gVAIMOByc5iTKMtTHqBxM9wATr+rz99+VDyFL/uf6z2WZg+w8GEANSZ/q0+XiB6qMs4FHesVHz5GHr73SVOm0obn2OVBOgwEEUmz96/rwFI/47aVF5dvmzJLDDunJr5/+4cLW0LzwG10m/RIOBrCDkdsQG8Olz6cxVJpD+iWcNIBMBIjz1p7trD7sVSV/TVVt2Ko82FWaVICxp4A99/T1YLdKz2fomnrJvKpRJxegf0zVMQ5XTuijCV02ANfLhB0dwKzXVuebvvK1Pl/Lm8G1apw719r284oRJSMDWJuINQG2L0ZbL7tY2APqrQyTVVKH5V3JEhhVzKYYbWucOkhNPyMDaDrwjbVat/+nr9TcgoxojGotJscwMoCx6VThsA+s+nfFyOkrXKh2n1z2AHR6SQ6UOaa1nXriOzJ368x2xiryKLSL4XxDl86FCBfC8RtqPACdXhqaV6vT2vJtDHtAdOJ5f+3dB9qRooxsx+pwJqDsATgBvbbsYnhG0UvQcjSu+0EB0B1yWUrHMvwlKHt1c5MCkC2MO4ysxHYmy6WdR4clrX+8JgUgEwciYh/4mPouMATvgouu7FfFwGtKX461psMYetnrbkS9B8KOZZ0W4x4rcmkZA9Tx5qo4BlNoG920S0t72Qf6YO9OlNZD6YdapPUHKGcAlQL1qGKuFD19hYZVaVkWww16pVNzzjOUhkb+Fq9fucbbzv5StRlMGQJfVFrKnHfqlWF5fcoQKldpoCzmUADL5sY4ujTri99xsAdEZh/YCC88h2K4QESFQWUSmmU3ofId5OjJxHxbmY+teTpVAHlDqTw2pfbPCk0MVPMZgSF/Bo4gciVktvYqxi6Lng6AiLrsphqrVzUMWi7GX2TcSNYCEbYvuhaj5c9W5r577hWk6zs6ARG1C2cNLNJwaICmG1jofoqMLtVOm27ZpD2A+Pz37pKtl2bfBwIPZ22/f6uGWIDULagdTkBEzVGYldUpxUCjoQFa3QS6KM0GXmlxcxvHH5FzLrlCGft6lQITiPdtv1fZ6kebvu3WQES2zc1XIleKtu/QAO1gofxNG77WR/t/8rKgkN/KuXN0FRARBSAiDVLd3tiMIgvDuERTIKKoU14Z6isMMNQiD0gSdVGV/AJp+bs+0L/+y18VW2xfaK/3D9bVtbcy1AMRkQci8oGkfhgBEcViWMN03MIAQy2s5iEXA067KpA6rZyAeMQbl8qJ67LvA6vwbHcgImxAJO0CpIlh0o98/OI+Ir5XnrkogB6rt3XAaDXX4PJfXAa89W95Q3XYN9zy7yReAREBEfETMTN5b4OIkXbEyLRVP3PvufHzkRaqyrNiFECPVfkmH6o5vzgCXkob/m/UmB/fVl96zXUBF9WZqgEiOmTnDj1xIAAEqerGAz/a8MzNNFuydI782r13NLa3HXp2YdA8k27Tdvu2b3vdWX389sOutBa4MrslICJV4QWZ4aa2zNnwqGmU22XNvROAtagRw+y5b9C1QOQWZLUZ8X/os0LXffAM7cOpnLoqBSZjICoPYWWhLbdvlvuLt3bW0AePNujml3+JpC5nAPVqF2DWT91rSMvpa/5QjD5+7pmyaPFbdUTzw5k9u3bpH9DwA+r1l1+m67ynyGTOVftHG6S5vQEJuB/t3avhcqvase88dKq8dNqZtsmfD7BxAUYG6I/qWrn9kGuVYgvDLUodMAFl9JmL/kbIY6d+EDF0tko/nTXfaW5Afn/jl/VzzlQCDn3o0vWycNkpxhxO6cBT6wL0OLQ1vWntx/Sb1geSZ+VV1/+L/OeO8bZho/5cGMSPFt/w3g/L3E9cI99/uSfPT/2Nop1ZeUBDgDOV2/75Oln2trebYqu0ABhYoa2C2c4xkOMPjxer0m7TNg80tHrFKYKe3PoNJwRjcAyqYIN745t+R4B35F23CFLVaYcFqwAYWKEqoOWtSm0PJuFbjSYOt13bFQk0BDRkYpGOX/hBuefaT5GNqhlcZN4WrF60F11peety+DRt6jHyxK3/FHYI1KSuSKABHGiIPKqGPeLJx+T1v/iKdyyAO+E1r05YcZF5W2y9AK366thq5dWrTrV6ylpOm3qsM3h+0lVrqAyZt8pYB0CqKxJwCGi8aMjjZ9+OVogiy3amKOQZbtXXPr4jL2XJVY/+SD69Y2dWSDlbM1YA69Ow6oPhAIeCDqqiClKZnCPUz/hD5TMSUIBD5NGfXvgZJ44p8OJAptyUAg39/vJ3Oq73fevO0NAcPwoKYLIv/gI0pAvq9Lo3N//qnF+XK9f0I7+mrDbEikNNK44O2Kjbb19sPpmN87v+6D1y3IJFNRffL0prTsqgAKpzwgE0ZFwBh35w9zeNSfh0sX3bd4pyVxlWHFugarz/275Zb9Crdh4ZrERUrQMcmnfCvKLqmV2P6Dz/oyf61GXZCgci0pWBUxLAEDgbnok/e+484ef4wDS2rtMQONOP+SMVx8yYaUxF+pa3LNF/j+at71urbc/u2yfTF5d7wPnvyW7nz153vcyceawceuTREryl1Z0SBQg4RE+sNgQ0hC2kefN/S1cBEelCB6cmcHQBPFYg+R9bf5DHPKjm/t6p+gI/eOsGXLTGb7tJ+M+mBt6aFcsErbvgk2L+ffGL/2BCGJPwhXAQYBUcrZrA4YPMvm+6WgEI2zBi68GzMI/hJN/89t1F2cwQiBiPLv6ozxh8MMn4t27VKSDJ/PHSObJ8wWvkC588m2KhGbMWysfOWye7dz6g9fhTzwvfWqPCSWW8AFed8vb+Sy+9JH95xumy466NWqnwGLxZASq+HH74FJKhtOD4Gd72Hzn/AuGvt5nKkxedIHNee5gucuHKPwll0IoYcK/atVm+cOHZBVjdSJ0AhzJwD8r9T/2ClqoSvpBA5I0cgPwhMAjPX7pS119y9Xr56IfO0tKGxBMQcQXkI+MPke1UgGPlnbbmvd64QETlCizdbrvhc3L5+X9eGvIc0FAG7gENDXhUAw2R539LITUqAI7lFr7+Qb+pIAISiJdcfbVejazI3M1JprzytKCTrBWAgwFJvgsB7qxzz5PpM2fqcM89+6xOQycg/src2UX1rNPeIWvULVsYVAZoKAVcFZ5qLj2zhlnkfC+HqLhDfSEJSCDOX7pKLlGrEQER4RMSAwcm9dxKpMPoby+9QlhxZsNrtiCLV73PCcuDX2tl9hIg/2enLZd1F5wjwLOdgYZagwOUFajHm8Qq6ywQEQUgovnOilyvVuQZSqfjEhQgkeNQGYBT5y30ZdGixbpGrwB1q+hUWcwLQWXdI9IH0FBrcKYH1b/JkvY4hQRERD0QUQnyar0qWY0In5C4vZGurwxA26KnMWGlGWjmgrN/W3Dqat3ykOceE6QLgRPQkA/ck+rbavsZV/QViGWbLYDhmQER0RCIKAO5Qj7d4tYGohGxjHw927bHt98pc+fNFb4wpY1J//+hrd4XAj5GQEMluEP0C4J6oCH6AhrCHtKmm28YQ3a9BTCy7vMqICICAHHL7bfLAnVr88Lh+YhYjQifmGyQeXjHvW+WmrIet+gk4Ssv++McL4NXyTOq1n8ADZXgzHZkzNmOAA35o2RWoKGs5J4tgCL8QS9kXIqJcYmMUaVAfEL9hyCVlTvUywZlK3Klvq0HBUk88XS67d/+UVetfedCuUbt3YCnDZ4T0FAdnDjg9uZ/8dwTojABDVWmX9STcQBiQEBENCzmQ4Ul6myQ2YrcLK1B5h1kK/LHYnYFYv9TnQEPk8qS1AQ01ASO1YbybmtxeDwADZnKkC/1XoBUICDuzv8yLuWQAImobwK5xtpisM0wZdry4TID+XRWVGdeDnzUUlnvATSUCs4bRBv7ArQNN64PXSPtVT1FARpnQCJTDqVARNSHQM44fqEwYXxsrVlxsv4Avyb/9L5kqgAAAq9JREFUIM+KA57tY68EYqDhwYkGt+nmL7UCZ8aVBNA4AxGZcigFIqI+A5nf2ieulAsv/jstJo/wydQ8fjxog7oDdwNhsyEMcG4F0MQHIjJlUnt1UEZAROQ1yC05SPXmDoPEux5txqwFeuWOBlwiQ49bFKDHn9kVAiLCEPMFIsJPg1RvbvOy8YMso5XgHtRfK/EBHxGLPRzipWCEPSSecaheX79gdR9l8bhFAXr8VZT6AURUr3EtQERYm0Bym6LdO5vBES8moKGYz6B1UYBtgwIRhdqZCwJEs4+0QbIhNytyd/5FJqsNEZPVhlJWG/5AQ+RTVK77FO/MxwNwkDBZMHMGIjJlkxK5n1MkD0hEPSDtDTnQEHVAQ+SBRxqSbx8n3s2l1P7lQ6vZYwYPwEHC0AVISEsBEZUWkbG6mwARifoHSLT/xRedTw6A2/PDvcojfLDa/Pu4QecU7svU9JgPMobB0/AggYiK2AFXICL8zJelGpz62IVNAgMFHNI+E3zqMReU1m9gBmmNy8/aDWGA6IALxAcaqlc3dFBvMLDFcwvHYuWoW4/PbcBqRLGefHUmCtCQzyez5ePMCu3PpqOEli0B5hHV+Op91C25t0pUA3WuHkBEVXuo/HXP93Eh36HsleH6ntsm/mAAVetKHxVLDKZyrRxARBVzUWS1ocIwwRmzc3C7zeYYBpjVu22SS3W8KU2BiIwv0JApH1hpNscwwKx+UsYMxE7ADbUI0qYeBpjW/oD10uwmYBH83AKssdNEu7/e7QGOYiCjiFllVSNadRis3B7gKAYSjDkRZAcDZ1q1B2haTkgaJDshvad00hHA0a2U4SMPHyEGsiOAo1spw0eOROiAbTNATyceU+wixes6DRbvqlYbYVvzDRhqAGvz8XTiMQXCJ5g7DZbQX8cuPwMAAP//rpq19QAAAAZJREFUAwAxqk5pcI6XawAAAABJRU5ErkJggg==',
};
// === 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 &middot; ${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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment