backpack-viewer 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/serve.js CHANGED
@@ -7,7 +7,7 @@ import http from "node:http";
7
7
 
8
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
9
  const root = path.resolve(__dirname, "..");
10
- const distDir = path.resolve(root, "dist");
10
+ const distDir = path.resolve(root, "dist/app");
11
11
  const port = parseInt(process.env.PORT || "5173", 10);
12
12
 
13
13
  const hasDistBuild = fs.existsSync(path.join(distDir, "index.html"));
package/dist/api.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { OntologyData, OntologySummary } from "backpack-ontology";
2
+ export declare function listOntologies(): Promise<OntologySummary[]>;
3
+ export declare function loadOntology(name: string): Promise<OntologyData>;
4
+ export declare function saveOntology(name: string, data: OntologyData): Promise<void>;
5
+ export declare function renameOntology(oldName: string, newName: string): Promise<void>;
package/dist/api.js ADDED
@@ -0,0 +1,30 @@
1
+ export async function listOntologies() {
2
+ const res = await fetch("/api/ontologies");
3
+ if (!res.ok)
4
+ return [];
5
+ return res.json();
6
+ }
7
+ export async function loadOntology(name) {
8
+ const res = await fetch(`/api/ontologies/${encodeURIComponent(name)}`);
9
+ if (!res.ok)
10
+ throw new Error(`Failed to load ontology: ${name}`);
11
+ return res.json();
12
+ }
13
+ export async function saveOntology(name, data) {
14
+ const res = await fetch(`/api/ontologies/${encodeURIComponent(name)}`, {
15
+ method: "PUT",
16
+ headers: { "Content-Type": "application/json" },
17
+ body: JSON.stringify(data),
18
+ });
19
+ if (!res.ok)
20
+ throw new Error(`Failed to save ontology: ${name}`);
21
+ }
22
+ export async function renameOntology(oldName, newName) {
23
+ const res = await fetch(`/api/ontologies/${encodeURIComponent(oldName)}/rename`, {
24
+ method: "POST",
25
+ headers: { "Content-Type": "application/json" },
26
+ body: JSON.stringify({ name: newName }),
27
+ });
28
+ if (!res.ok)
29
+ throw new Error(`Failed to rename ontology: ${oldName}`);
30
+ }
@@ -0,0 +1 @@
1
+ *{margin:0;padding:0;box-sizing:border-box}:root{--bg: #141414;--bg-surface: #1a1a1a;--bg-hover: #222222;--bg-active: #2a2a2a;--bg-elevated: #1e1e1e;--bg-inset: #111111;--border: #2a2a2a;--text: #d4d4d4;--text-strong: #e5e5e5;--text-muted: #737373;--text-dim: #525252;--accent: #d4a27f;--accent-hover: #e8b898;--badge-text: #141414;--glass-bg: rgba(20, 20, 20, .85);--glass-border: rgba(255, 255, 255, .08);--chip-bg: rgba(42, 42, 42, .7);--chip-bg-active: rgba(42, 42, 42, .9);--chip-bg-hover: rgba(50, 50, 50, .9);--chip-border-active: rgba(255, 255, 255, .06);--shadow: rgba(0, 0, 0, .6);--shadow-strong: rgba(0, 0, 0, .5);--canvas-edge: rgba(255, 255, 255, .08);--canvas-edge-highlight: rgba(212, 162, 127, .5);--canvas-edge-dim: rgba(255, 255, 255, .03);--canvas-edge-label: rgba(255, 255, 255, .2);--canvas-edge-label-highlight: rgba(212, 162, 127, .7);--canvas-edge-label-dim: rgba(255, 255, 255, .05);--canvas-arrow: rgba(255, 255, 255, .12);--canvas-arrow-highlight: rgba(212, 162, 127, .5);--canvas-node-label: #a3a3a3;--canvas-node-label-dim: rgba(212, 212, 212, .2);--canvas-type-badge: rgba(115, 115, 115, .5);--canvas-type-badge-dim: rgba(115, 115, 115, .15);--canvas-selection-border: #d4d4d4;--canvas-node-border: rgba(255, 255, 255, .15)}[data-theme=light]{--bg: #f5f5f4;--bg-surface: #fafaf9;--bg-hover: #f0efee;--bg-active: #e7e5e4;--bg-elevated: #f0efee;--bg-inset: #e7e5e4;--border: #d6d3d1;--text: #292524;--text-strong: #1c1917;--text-muted: #78716c;--text-dim: #a8a29e;--accent: #c17856;--accent-hover: #b07a5e;--badge-text: #fafaf9;--glass-bg: rgba(250, 250, 249, .85);--glass-border: rgba(0, 0, 0, .08);--chip-bg: rgba(214, 211, 209, .5);--chip-bg-active: rgba(214, 211, 209, .8);--chip-bg-hover: rgba(200, 197, 195, .8);--chip-border-active: rgba(0, 0, 0, .08);--shadow: rgba(0, 0, 0, .1);--shadow-strong: rgba(0, 0, 0, .15);--canvas-edge: rgba(0, 0, 0, .1);--canvas-edge-highlight: rgba(193, 120, 86, .6);--canvas-edge-dim: rgba(0, 0, 0, .03);--canvas-edge-label: rgba(0, 0, 0, .25);--canvas-edge-label-highlight: rgba(193, 120, 86, .8);--canvas-edge-label-dim: rgba(0, 0, 0, .06);--canvas-arrow: rgba(0, 0, 0, .15);--canvas-arrow-highlight: rgba(193, 120, 86, .6);--canvas-node-label: #57534e;--canvas-node-label-dim: rgba(87, 83, 78, .2);--canvas-type-badge: rgba(87, 83, 78, .5);--canvas-type-badge-dim: rgba(87, 83, 78, .15);--canvas-selection-border: #292524;--canvas-node-border: rgba(0, 0, 0, .1)}body{font-family:system-ui,-apple-system,sans-serif;background:var(--bg);color:var(--text);overflow:hidden}#app{display:flex;height:100vh;width:100vw}#sidebar{width:280px;min-width:280px;background:var(--bg-surface);border-right:1px solid var(--border);display:flex;flex-direction:column;padding:16px;overflow-y:auto}#sidebar h2{font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text-muted);margin-bottom:14px}#sidebar input{width:100%;padding:8px 12px;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font-size:13px;outline:none;margin-bottom:12px}#sidebar input:focus{border-color:var(--accent)}#sidebar input::placeholder{color:var(--text-dim)}#ontology-list{list-style:none;display:flex;flex-direction:column;gap:2px}.ontology-item{padding:10px 12px;border-radius:6px;cursor:pointer;transition:background .15s}.ontology-item:hover{background:var(--bg-hover)}.ontology-item.active{background:var(--bg-active)}.ontology-item .name{display:block;font-size:13px;font-weight:500;color:var(--text)}.ontology-item .stats{display:block;font-size:11px;color:var(--text-dim);margin-top:2px}.sidebar-edit-btn{position:absolute;right:8px;top:10px;background:none;border:none;color:var(--text-dim);font-size:11px;cursor:pointer;opacity:0;transition:opacity .1s}.ontology-item{position:relative}.ontology-item:hover .sidebar-edit-btn{opacity:.7}.sidebar-edit-btn:hover{opacity:1!important;color:var(--text)}.sidebar-rename-input{background:transparent;border:none;border-bottom:1px solid var(--accent);color:var(--text);font:inherit;font-size:13px;font-weight:500;outline:none;width:100%;padding:0}.sidebar-footer{margin-top:auto;padding-top:16px;border-top:1px solid var(--border);text-align:center}.sidebar-footer a{display:block;font-size:12px;font-weight:500;color:var(--accent);text-decoration:none;margin-bottom:4px}.sidebar-footer a:hover{color:var(--accent-hover)}.sidebar-footer span{display:block;font-size:10px;color:var(--text-dim)}.theme-toggle{position:absolute;top:16px;right:16px;z-index:30;background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;color:var(--text-muted);font-size:18px;cursor:pointer;padding:6px 10px;line-height:1;transition:color .15s,border-color .15s,background .15s;box-shadow:0 2px 8px var(--shadow)}.theme-toggle:hover{color:var(--text);border-color:var(--text-muted);background:var(--bg-hover)}.zoom-controls{position:absolute;top:16px;right:16px;z-index:30;display:flex;gap:4px;transition:right .2s ease}.theme-toggle~.zoom-controls{right:58px}.zoom-btn{background:var(--bg-surface);border:1px solid var(--border);border-radius:8px;color:var(--text-muted);font-size:18px;cursor:pointer;padding:6px 10px;line-height:1;transition:color .15s,border-color .15s,background .15s;box-shadow:0 2px 8px var(--shadow)}.zoom-btn:hover{color:var(--text);border-color:var(--text-muted);background:var(--bg-hover)}#canvas-container{flex:1;position:relative;overflow:hidden}#graph-canvas{position:absolute;top:0;left:0;width:100%;height:100%;cursor:grab}#graph-canvas:active{cursor:grabbing}.search-overlay{position:absolute;top:16px;left:50%;transform:translate(-50%);z-index:20;display:flex;flex-direction:column;align-items:center;gap:8px;max-height:calc(100vh - 48px);pointer-events:none}.search-overlay>*{pointer-events:auto}.search-overlay.hidden{display:none}.search-input-wrap{position:relative;width:380px;max-width:calc(100vw - 340px)}.search-input{width:100%;padding:10px 36px 10px 16px;border:1px solid var(--glass-border);border-radius:10px;background:var(--glass-bg);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);color:var(--text);font-size:14px;outline:none;transition:border-color .15s,box-shadow .15s}.search-input:focus{border-color:#d4a27f66;box-shadow:0 0 0 3px #d4a27f1a}.search-input::placeholder{color:var(--text-dim)}.search-kbd{position:absolute;right:10px;top:50%;transform:translateY(-50%);padding:2px 7px;border:1px solid var(--border);border-radius:4px;background:var(--bg-surface);color:var(--text-dim);font-size:11px;font-family:monospace;pointer-events:none}.search-kbd.hidden{display:none}.search-results{list-style:none;width:380px;max-width:calc(100vw - 340px);background:var(--glass-bg);backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);border:1px solid var(--border);border-radius:10px;overflow:hidden;box-shadow:0 8px 32px var(--shadow-strong)}.search-results.hidden{display:none}.search-result-item{display:flex;align-items:center;gap:8px;padding:8px 14px;cursor:pointer;transition:background .1s}.search-result-item:hover{background:var(--bg-hover)}.search-result-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}.search-result-label{font-size:13px;color:var(--text);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.search-result-type{font-size:11px;color:var(--text-dim);flex-shrink:0}.type-chips{display:flex;flex-wrap:wrap;gap:4px;justify-content:center;max-width:500px;max-height:200px;overflow-y:auto;padding:4px;border-radius:10px}.type-chip{display:flex;align-items:center;gap:4px;padding:3px 10px;border:1px solid transparent;border-radius:12px;background:var(--chip-bg);color:var(--text-dim);font-size:11px;cursor:pointer;transition:all .15s;white-space:nowrap}.type-chip.active{background:var(--chip-bg-active);color:var(--text-muted);border-color:var(--chip-border-active)}.type-chip:hover{background:var(--chip-bg-hover)}.type-chip-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}.type-chip:not(.active) .type-chip-dot{opacity:.3}.info-panel{position:absolute;top:56px;right:16px;width:360px;max-height:calc(100vh - 72px);background:var(--bg-surface);border:1px solid var(--border);border-radius:10px;overflow-y:auto;padding:20px;z-index:10;box-shadow:0 8px 32px var(--shadow);transition:top .25s ease,right .25s ease,bottom .25s ease,left .25s ease,width .25s ease,max-height .25s ease,border-radius .25s ease}.info-panel.hidden{display:none}.info-panel.info-panel-maximized{top:0;right:0;bottom:0;left:0;width:auto;max-height:none;border-radius:0;z-index:40}.info-panel-toolbar{position:absolute;top:12px;right:14px;display:flex;align-items:center;gap:2px;z-index:1}.info-toolbar-btn{background:none;border:none;color:var(--text-muted);font-size:16px;cursor:pointer;padding:4px 6px;line-height:1;border-radius:4px;transition:color .15s,background .15s}.info-toolbar-btn:hover:not(:disabled){color:var(--text);background:var(--bg-hover)}.info-toolbar-btn:disabled{color:var(--text-dim);cursor:default;opacity:.3}.info-close-btn{font-size:20px}.info-connection-link{cursor:pointer;transition:background .15s}.info-connection-link:hover{background:var(--bg-active)}.info-connection-link .info-target{color:var(--accent);text-decoration:underline;text-decoration-color:transparent;transition:text-decoration-color .15s}.info-connection-link:hover .info-target{text-decoration-color:var(--accent)}.info-header{margin-bottom:16px}.info-type-badge{display:inline-block;padding:3px 10px;border-radius:12px;font-size:11px;font-weight:600;color:var(--badge-text);margin-bottom:8px}.info-label{font-size:18px;font-weight:600;color:var(--text-strong);margin-bottom:4px;word-break:break-word}.info-id{display:block;font-size:11px;color:var(--text-dim);font-family:monospace}.info-section{margin-bottom:16px}.info-section-title{font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text-muted);margin-bottom:8px;padding-bottom:4px;border-bottom:1px solid var(--border)}.info-props{display:grid;grid-template-columns:auto 1fr;gap:4px 12px}.info-props dt{font-size:12px;color:var(--text-muted);padding-top:2px}.info-props dd{font-size:12px;color:var(--text);word-break:break-word;display:flex;align-items:center;gap:4px}.info-value{white-space:pre-wrap}.info-array{display:flex;flex-wrap:wrap;gap:4px}.info-tag{display:inline-block;padding:2px 8px;background:var(--bg-hover);border-radius:4px;font-size:11px;color:var(--text-muted)}.info-json{font-size:11px;font-family:monospace;color:var(--text-muted);background:var(--bg-inset);padding:6px 8px;border-radius:4px;overflow-x:auto;white-space:pre}.info-connections{list-style:none;display:flex;flex-direction:column;gap:6px}.info-connection{display:flex;align-items:center;gap:6px;padding:6px 8px;background:var(--bg-elevated);border-radius:6px;font-size:12px;flex-wrap:wrap}.info-target-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}.info-arrow{color:var(--text-dim);font-size:14px;flex-shrink:0}.info-edge-type{color:var(--text-muted);font-size:11px;font-weight:500}.info-target{color:var(--text);font-weight:500}.info-edge-props{width:100%;padding-top:4px;padding-left:20px}.info-edge-prop{display:block;font-size:11px;color:var(--text-dim)}.info-editable{cursor:default;position:relative}.info-inline-edit{background:none;border:none;color:var(--badge-text);opacity:0;font-size:10px;cursor:pointer;margin-left:4px;transition:opacity .15s}.info-editable:hover .info-inline-edit{opacity:.8}.info-inline-edit:hover{opacity:1!important}.info-edit-inline-input{background:transparent;border:none;border-bottom:1px solid var(--accent);color:var(--badge-text);font:inherit;font-size:inherit;outline:none;width:100%;padding:0}.info-edit-input{background:var(--bg-inset);border:1px solid var(--border);border-radius:4px;padding:3px 6px;font-size:12px;color:var(--text);flex:1;min-width:0}.info-edit-input:focus{outline:none;border-color:var(--accent)}.info-delete-prop{background:none;border:none;color:var(--text-dim);font-size:14px;cursor:pointer;padding:0 2px;flex-shrink:0;opacity:0;transition:opacity .1s,color .1s}.info-props dd:hover .info-delete-prop{opacity:1}.info-delete-prop:hover{color:#ef4444}.info-add-btn{background:none;border:1px dashed var(--border);border-radius:4px;padding:6px 10px;font-size:12px;color:var(--text-dim);cursor:pointer;width:100%;margin-top:8px;transition:border-color .15s,color .15s}.info-add-btn:hover{border-color:var(--accent);color:var(--text)}.info-add-row{display:flex;gap:4px;margin-top:6px}.info-add-save{background:var(--accent);border:none;border-radius:4px;padding:3px 10px;font-size:12px;color:var(--badge-text);cursor:pointer;flex-shrink:0}.info-add-save:hover{background:var(--accent-hover)}.info-delete-edge{background:none;border:none;color:var(--text-dim);font-size:14px;cursor:pointer;margin-left:auto;padding:0 2px;opacity:0;transition:opacity .1s,color .1s}.info-connection:hover .info-delete-edge{opacity:1}.info-delete-edge:hover{color:#ef4444}.info-danger{margin-top:8px;padding-top:12px;border-top:1px solid var(--border)}.info-delete-node{background:none;border:1px solid rgba(239,68,68,.3);border-radius:6px;padding:6px 12px;font-size:12px;color:#ef4444;cursor:pointer;width:100%;transition:background .15s}.info-delete-node:hover{background:#ef44441a}
@@ -0,0 +1 @@
1
+ (function(){const o=document.createElement("link").relList;if(o&&o.supports&&o.supports("modulepreload"))return;for(const d of document.querySelectorAll('link[rel="modulepreload"]'))t(d);new MutationObserver(d=>{for(const e of d)if(e.type==="childList")for(const i of e.addedNodes)i.tagName==="LINK"&&i.rel==="modulepreload"&&t(i)}).observe(document,{childList:!0,subtree:!0});function s(d){const e={};return d.integrity&&(e.integrity=d.integrity),d.referrerPolicy&&(e.referrerPolicy=d.referrerPolicy),d.crossOrigin==="use-credentials"?e.credentials="include":d.crossOrigin==="anonymous"?e.credentials="omit":e.credentials="same-origin",e}function t(d){if(d.ep)return;d.ep=!0;const e=s(d);fetch(d.href,e)}})();async function ie(){const a=await fetch("/api/ontologies");return a.ok?a.json():[]}async function le(a){const o=await fetch(`/api/ontologies/${encodeURIComponent(a)}`);if(!o.ok)throw new Error(`Failed to load ontology: ${a}`);return o.json()}async function Ne(a,o){if(!(await fetch(`/api/ontologies/${encodeURIComponent(a)}`,{method:"PUT",headers:{"Content-Type":"application/json"},body:JSON.stringify(o)})).ok)throw new Error(`Failed to save ontology: ${a}`)}async function Se(a,o){if(!(await fetch(`/api/ontologies/${encodeURIComponent(a)}/rename`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:o})})).ok)throw new Error(`Failed to rename ontology: ${a}`)}function Me(a,o){const s=typeof o=="function"?{onSelect:o}:o,t=document.createElement("h2");t.textContent="Backpack Ontology Viewer";const d=document.createElement("input");d.type="text",d.placeholder="Filter...",d.id="filter";const e=document.createElement("ul");e.id="ontology-list";const i=document.createElement("div");i.className="sidebar-footer",i.innerHTML='<a href="mailto:support@backpackontology.com">support@backpackontology.com</a><span>Feedback & support</span>',a.appendChild(t),a.appendChild(d),a.appendChild(e),a.appendChild(i);let v=[],f="";return d.addEventListener("input",()=>{const h=d.value.toLowerCase();for(const m of v){const c=m.dataset.name??"";m.style.display=c.includes(h)?"":"none"}}),{setSummaries(h){e.innerHTML="",v=h.map(m=>{const c=document.createElement("li");c.className="ontology-item",c.dataset.name=m.name;const y=document.createElement("span");y.className="name",y.textContent=m.name;const M=document.createElement("span");if(M.className="stats",M.textContent=`${m.nodeCount} nodes, ${m.edgeCount} edges`,c.appendChild(y),c.appendChild(M),s.onRename){const I=document.createElement("button");I.className="sidebar-edit-btn",I.textContent="✎",I.title="Rename";const G=s.onRename;I.addEventListener("click",u=>{u.stopPropagation();const x=document.createElement("input");x.type="text",x.className="sidebar-rename-input",x.value=m.name,y.textContent="",y.appendChild(x),I.style.display="none",x.focus(),x.select();const r=()=>{const C=x.value.trim();C&&C!==m.name?G(m.name,C):(y.textContent=m.name,I.style.display="")};x.addEventListener("blur",r),x.addEventListener("keydown",C=>{C.key==="Enter"&&x.blur(),C.key==="Escape"&&(x.value=m.name,x.blur())})}),c.appendChild(I)}return c.addEventListener("click",()=>s.onSelect(m.name)),e.appendChild(c),c}),f&&this.setActive(f)},setActive(h){f=h;for(const m of v)m.classList.toggle("active",m.dataset.name===h)}}}const Ie=5e3,ke=.005,ve=150,me=.9,fe=.01,he=30,de=50;function Ae(a,o){for(const s of Object.values(a))if(typeof s=="string")return s;return o}function Te(a){const o=Math.sqrt(a.nodes.length)*ve*.5,s=new Map,t=a.nodes.map((e,i)=>{const v=2*Math.PI*i/a.nodes.length,f={id:e.id,x:Math.cos(v)*o,y:Math.sin(v)*o,vx:0,vy:0,label:Ae(e.properties,e.id),type:e.type};return s.set(e.id,f),f}),d=a.edges.map(e=>({sourceId:e.sourceId,targetId:e.targetId,type:e.type}));return{nodes:t,edges:d,nodeMap:s}}function Oe(a,o){const{nodes:s,edges:t,nodeMap:d}=a;for(let e=0;e<s.length;e++)for(let i=e+1;i<s.length;i++){const v=s[e],f=s[i];let h=f.x-v.x,m=f.y-v.y,c=Math.sqrt(h*h+m*m);c<he&&(c=he);const y=Ie*o/(c*c),M=h/c*y,I=m/c*y;v.vx-=M,v.vy-=I,f.vx+=M,f.vy+=I}for(const e of t){const i=d.get(e.sourceId),v=d.get(e.targetId);if(!i||!v)continue;const f=v.x-i.x,h=v.y-i.y,m=Math.sqrt(f*f+h*h);if(m===0)continue;const c=ke*(m-ve)*o,y=f/m*c,M=h/m*c;i.vx+=y,i.vy+=M,v.vx-=y,v.vy-=M}for(const e of s)e.vx-=e.x*fe*o,e.vy-=e.y*fe*o;for(const e of s){e.vx*=me,e.vy*=me;const i=Math.sqrt(e.vx*e.vx+e.vy*e.vy);i>de&&(e.vx=e.vx/i*de,e.vy=e.vy/i*de),e.x+=e.vx,e.y+=e.vy}return o*.995}const ge=["#d4a27f","#c17856","#b07a5e","#d4956b","#a67c5a","#cc9e7c","#c4866a","#cb8e6c","#b8956e","#a88a70","#d9b08c","#c4a882","#e8b898","#b5927a","#a8886e","#d1a990"],ye=new Map;function Z(a){const o=ye.get(a);if(o)return o;let s=0;for(let d=0;d<a.length;d++)s=(s<<5)-s+a.charCodeAt(d)|0;const t=ge[Math.abs(s)%ge.length];return ye.set(a,t),t}function q(a){return getComputedStyle(document.documentElement).getPropertyValue(a).trim()}const J=20,De=.001;function Pe(a,o){const s=a.querySelector("canvas"),t=s.getContext("2d"),d=window.devicePixelRatio||1;let e={x:0,y:0,scale:1},i=null,v=1,f=0,h=new Set,m=null,c=null,y=null;const M=300;function I(){s.width=s.clientWidth*d,s.height=s.clientHeight*d,r()}const G=new ResizeObserver(I);G.observe(a),I();function u(n,l){return[n/e.scale+e.x,l/e.scale+e.y]}function x(n,l){if(!i)return null;const[p,g]=u(n,l);for(let N=i.nodes.length-1;N>=0;N--){const b=i.nodes[N],B=p-b.x,w=g-b.y;if(B*B+w*w<=J*J)return b}return null}function r(){if(!i){t.clearRect(0,0,s.width,s.height);return}const n=q("--canvas-edge"),l=q("--canvas-edge-highlight"),p=q("--canvas-edge-dim"),g=q("--canvas-edge-label"),N=q("--canvas-edge-label-highlight"),b=q("--canvas-edge-label-dim"),B=q("--canvas-arrow"),w=q("--canvas-arrow-highlight"),H=q("--canvas-node-label"),R=q("--canvas-node-label-dim"),P=q("--canvas-type-badge"),ee=q("--canvas-type-badge-dim"),ne=q("--canvas-selection-border"),be=q("--canvas-node-border");t.save(),t.setTransform(d,0,0,d,0,0),t.clearRect(0,0,s.clientWidth,s.clientHeight),t.save(),t.translate(-e.x*e.scale,-e.y*e.scale),t.scale(e.scale,e.scale);for(const T of i.edges){const K=i.nodeMap.get(T.sourceId),V=i.nodeMap.get(T.targetId);if(!K||!V)continue;const ce=m===null||m.has(T.sourceId),oe=m===null||m.has(T.targetId),te=ce&&oe;if(m!==null&&!ce&&!oe)continue;const _=h.size>0&&(h.has(T.sourceId)||h.has(T.targetId))||m!==null&&te,ue=m!==null&&!te;if(T.sourceId===T.targetId){S(K,T.type,_,n,l,g,N);continue}t.beginPath(),t.moveTo(K.x,K.y),t.lineTo(V.x,V.y),t.strokeStyle=_?l:ue?p:n,t.lineWidth=_?2.5:1.5,t.stroke(),C(K.x,K.y,V.x,V.y,_,B,w);const Le=(K.x+V.x)/2,we=(K.y+V.y)/2;t.fillStyle=_?N:ue?b:g,t.font="9px system-ui, sans-serif",t.textAlign="center",t.textBaseline="bottom",t.fillText(T.type,Le,we-4)}for(const T of i.nodes){const K=Z(T.type),V=h.has(T.id),ce=h.size>0&&i.edges.some(_=>h.has(_.sourceId)&&_.targetId===T.id||h.has(_.targetId)&&_.sourceId===T.id),oe=m!==null&&!m.has(T.id),te=oe||h.size>0&&!V&&!ce;V&&(t.save(),t.shadowColor=K,t.shadowBlur=20,t.beginPath(),t.arc(T.x,T.y,J+3,0,Math.PI*2),t.fillStyle=K,t.globalAlpha=.3,t.fill(),t.restore()),t.beginPath(),t.arc(T.x,T.y,J,0,Math.PI*2),t.fillStyle=K,t.globalAlpha=oe?.1:te?.3:1,t.fill(),t.strokeStyle=V?ne:be,t.lineWidth=V?3:1.5,t.stroke();const pe=T.label.length>24?T.label.slice(0,22)+"...":T.label;t.fillStyle=te?R:H,t.font="11px system-ui, sans-serif",t.textAlign="center",t.textBaseline="top",t.fillText(pe,T.x,T.y+J+4),t.fillStyle=te?ee:P,t.font="9px system-ui, sans-serif",t.textBaseline="bottom",t.fillText(T.type,T.x,T.y-J-3),t.globalAlpha=1}t.restore(),t.restore()}function C(n,l,p,g,N,b,B){const w=Math.atan2(g-l,p-n),H=p-Math.cos(w)*J,R=g-Math.sin(w)*J,P=8;t.beginPath(),t.moveTo(H,R),t.lineTo(H-P*Math.cos(w-.4),R-P*Math.sin(w-.4)),t.lineTo(H-P*Math.cos(w+.4),R-P*Math.sin(w+.4)),t.closePath(),t.fillStyle=N?B:b,t.fill()}function S(n,l,p,g,N,b,B){const w=n.x+J+15,H=n.y-J-15;t.beginPath(),t.arc(w,H,15,0,Math.PI*2),t.strokeStyle=p?N:g,t.lineWidth=p?2.5:1.5,t.stroke(),t.fillStyle=p?B:b,t.font="9px system-ui, sans-serif",t.textAlign="center",t.fillText(l,w,H-18)}function O(){if(!c||!y)return;const n=performance.now()-y.time,l=Math.min(n/M,1),p=1-Math.pow(1-l,3);e.x=y.x+(c.x-y.x)*p,e.y=y.y+(c.y-y.y)*p,r(),l<1?requestAnimationFrame(O):(c=null,y=null)}function j(){!i||v<De||(v=Oe(i,v),r(),f=requestAnimationFrame(j))}let F=!1,W=!1,X=0,U=0;s.addEventListener("mousedown",n=>{F=!0,W=!1,X=n.clientX,U=n.clientY}),s.addEventListener("mousemove",n=>{if(!F)return;const l=n.clientX-X,p=n.clientY-U;(Math.abs(l)>2||Math.abs(p)>2)&&(W=!0),e.x-=l/e.scale,e.y-=p/e.scale,X=n.clientX,U=n.clientY,r()}),s.addEventListener("mouseup",n=>{if(F=!1,W)return;const l=s.getBoundingClientRect(),p=n.clientX-l.left,g=n.clientY-l.top,N=x(p,g),b=n.ctrlKey||n.metaKey;if(N){b?h.has(N.id)?h.delete(N.id):h.add(N.id):h.size===1&&h.has(N.id)?h.clear():(h.clear(),h.add(N.id));const B=[...h];o==null||o(B.length>0?B:null)}else h.clear(),o==null||o(null);r()}),s.addEventListener("mouseleave",()=>{F=!1}),s.addEventListener("wheel",n=>{n.preventDefault();const l=s.getBoundingClientRect(),p=n.clientX-l.left,g=n.clientY-l.top,[N,b]=u(p,g),B=n.ctrlKey?1-n.deltaY*.01:n.deltaY>0?.9:1.1;e.scale=Math.max(.05,Math.min(10,e.scale*B)),e.x=N-p/e.scale,e.y=b-g/e.scale,r()},{passive:!1});let z=[],A=0,D=1;s.addEventListener("touchstart",n=>{n.preventDefault(),z=Array.from(n.touches),z.length===2?(A=$(z[0],z[1]),D=e.scale):z.length===1&&(X=z[0].clientX,U=z[0].clientY)},{passive:!1}),s.addEventListener("touchmove",n=>{n.preventDefault();const l=Array.from(n.touches);if(l.length===2&&z.length===2){const g=$(l[0],l[1])/A;e.scale=Math.max(.05,Math.min(10,D*g)),r()}else if(l.length===1){const p=l[0].clientX-X,g=l[0].clientY-U;e.x-=p/e.scale,e.y-=g/e.scale,X=l[0].clientX,U=l[0].clientY,r()}z=l},{passive:!1});function $(n,l){const p=n.clientX-l.clientX,g=n.clientY-l.clientY;return Math.sqrt(p*p+g*g)}const Y=document.createElement("div");Y.className="zoom-controls";const E=document.createElement("button");E.className="zoom-btn",E.textContent="+",E.title="Zoom in",E.addEventListener("click",()=>{const n=s.clientWidth/2,l=s.clientHeight/2,[p,g]=u(n,l);e.scale=Math.min(10,e.scale*1.3),e.x=p-n/e.scale,e.y=g-l/e.scale,r()});const k=document.createElement("button");return k.className="zoom-btn",k.textContent="−",k.title="Zoom out",k.addEventListener("click",()=>{const n=s.clientWidth/2,l=s.clientHeight/2,[p,g]=u(n,l);e.scale=Math.max(.05,e.scale/1.3),e.x=p-n/e.scale,e.y=g-l/e.scale,r()}),Y.appendChild(E),Y.appendChild(k),a.appendChild(Y),{loadGraph(n){if(cancelAnimationFrame(f),i=Te(n),v=1,h=new Set,m=null,e={x:0,y:0,scale:1},i.nodes.length>0){let l=1/0,p=1/0,g=-1/0,N=-1/0;for(const R of i.nodes)R.x<l&&(l=R.x),R.y<p&&(p=R.y),R.x>g&&(g=R.x),R.y>N&&(N=R.y);const b=(l+g)/2,B=(p+N)/2,w=s.clientWidth,H=s.clientHeight;e.x=b-w/2,e.y=B-H/2}j()},setFilteredNodeIds(n){m=n,r()},panToNode(n){if(!i)return;const l=i.nodeMap.get(n);if(!l)return;h=new Set([n]),o==null||o([n]);const p=s.clientWidth,g=s.clientHeight;y={x:e.x,y:e.y,time:performance.now()},c={x:l.x-p/(2*e.scale),y:l.y-g/(2*e.scale)},O()},destroy(){cancelAnimationFrame(f),G.disconnect()}}}function se(a){for(const o of Object.values(a.properties))if(typeof o=="string")return o;return a.id}const Be="✎";function Re(a,o,s){const t=document.createElement("div");t.id="info-panel",t.className="info-panel hidden",a.appendChild(t);let d=!1,e=[],i=-1,v=!1,f=null;function h(){t.classList.add("hidden"),t.classList.remove("info-panel-maximized"),t.innerHTML="",d=!1,e=[],i=-1}function m(u){!f||!s||(i<e.length-1&&(e=e.slice(0,i+1)),e.push(u),i=e.length-1,v=!0,s(u),v=!1)}function c(){i<=0||!f||!s||(i--,v=!0,s(e[i]),v=!1)}function y(){i>=e.length-1||!f||!s||(i++,v=!0,s(e[i]),v=!1)}function M(){const u=document.createElement("div");u.className="info-panel-toolbar";const x=document.createElement("button");x.className="info-toolbar-btn",x.textContent="←",x.title="Back",x.disabled=i<=0,x.addEventListener("click",c),u.appendChild(x);const r=document.createElement("button");r.className="info-toolbar-btn",r.textContent="→",r.title="Forward",r.disabled=i>=e.length-1,r.addEventListener("click",y),u.appendChild(r);const C=document.createElement("button");C.className="info-toolbar-btn",C.textContent=d?"⎘":"⛶",C.title=d?"Restore":"Maximize",C.addEventListener("click",()=>{d=!d,t.classList.toggle("info-panel-maximized",d),C.textContent=d?"⎘":"⛶",C.title=d?"Restore":"Maximize"}),u.appendChild(C);const S=document.createElement("button");return S.className="info-toolbar-btn info-close-btn",S.textContent="×",S.title="Close",S.addEventListener("click",h),u.appendChild(S),u}function I(u,x){const r=x.nodes.find(E=>E.id===u);if(!r)return;const C=x.edges.filter(E=>E.sourceId===u||E.targetId===u);t.innerHTML="",t.classList.remove("hidden"),d&&t.classList.add("info-panel-maximized"),t.appendChild(M());const S=document.createElement("div");S.className="info-header";const O=document.createElement("span");if(O.className="info-type-badge",O.textContent=r.type,O.style.backgroundColor=Z(r.type),o){O.classList.add("info-editable");const E=document.createElement("button");E.className="info-inline-edit",E.textContent=Be,E.addEventListener("click",k=>{k.stopPropagation();const n=document.createElement("input");n.type="text",n.className="info-edit-inline-input",n.value=r.type,O.textContent="",O.appendChild(n),n.focus(),n.select();const l=()=>{const p=n.value.trim();p&&p!==r.type?o.onChangeNodeType(u,p):(O.textContent=r.type,O.appendChild(E))};n.addEventListener("blur",l),n.addEventListener("keydown",p=>{p.key==="Enter"&&n.blur(),p.key==="Escape"&&(n.value=r.type,n.blur())})}),O.appendChild(E)}const j=document.createElement("h3");j.className="info-label",j.textContent=se(r);const F=document.createElement("span");F.className="info-id",F.textContent=r.id,S.appendChild(O),S.appendChild(j),S.appendChild(F),t.appendChild(S);const W=Object.keys(r.properties),X=ae("Properties");if(W.length>0){const E=document.createElement("dl");E.className="info-props";for(const k of W){const n=document.createElement("dt");n.textContent=k;const l=document.createElement("dd");if(o){const p=re(r.properties[k]),g=document.createElement("input");g.type="text",g.className="info-edit-input",g.value=p,g.addEventListener("keydown",b=>{b.key==="Enter"&&g.blur()}),g.addEventListener("blur",()=>{const b=g.value;b!==p&&o.onUpdateNode(u,{[k]:$e(b)})}),l.appendChild(g);const N=document.createElement("button");N.className="info-delete-prop",N.textContent="×",N.title=`Remove ${k}`,N.addEventListener("click",()=>{const b={...r.properties};delete b[k],o.onUpdateNode(u,b)}),l.appendChild(N)}else l.appendChild(ze(r.properties[k]));E.appendChild(n),E.appendChild(l)}X.appendChild(E)}if(o){const E=document.createElement("button");E.className="info-add-btn",E.textContent="+ Add property",E.addEventListener("click",()=>{const k=document.createElement("div");k.className="info-add-row";const n=document.createElement("input");n.type="text",n.className="info-edit-input",n.placeholder="key";const l=document.createElement("input");l.type="text",l.className="info-edit-input",l.placeholder="value";const p=document.createElement("button");p.className="info-add-save",p.textContent="Add",p.addEventListener("click",()=>{n.value&&o.onAddProperty(u,n.value,l.value)}),k.appendChild(n),k.appendChild(l),k.appendChild(p),X.appendChild(k),n.focus()}),X.appendChild(E)}if(t.appendChild(X),C.length>0){const E=ae(`Connections (${C.length})`),k=document.createElement("ul");k.className="info-connections";for(const n of C){const l=n.sourceId===u,p=l?n.targetId:n.sourceId,g=x.nodes.find(P=>P.id===p),N=g?se(g):p,b=document.createElement("li");if(b.className="info-connection",s&&g&&(b.classList.add("info-connection-link"),b.addEventListener("click",P=>{P.target.closest(".info-delete-edge")||m(p)})),g){const P=document.createElement("span");P.className="info-target-dot",P.style.backgroundColor=Z(g.type),b.appendChild(P)}const B=document.createElement("span");B.className="info-arrow",B.textContent=l?"→":"←";const w=document.createElement("span");w.className="info-edge-type",w.textContent=n.type;const H=document.createElement("span");H.className="info-target",H.textContent=N,b.appendChild(B),b.appendChild(w),b.appendChild(H);const R=Object.keys(n.properties);if(R.length>0){const P=document.createElement("div");P.className="info-edge-props";for(const ee of R){const ne=document.createElement("span");ne.className="info-edge-prop",ne.textContent=`${ee}: ${re(n.properties[ee])}`,P.appendChild(ne)}b.appendChild(P)}if(o){const P=document.createElement("button");P.className="info-delete-edge",P.textContent="×",P.title="Remove connection",P.addEventListener("click",ee=>{ee.stopPropagation(),o.onDeleteEdge(n.id)}),b.appendChild(P)}k.appendChild(b)}E.appendChild(k),t.appendChild(E)}const U=ae("Timestamps"),z=document.createElement("dl");z.className="info-props";const A=document.createElement("dt");A.textContent="created";const D=document.createElement("dd");D.textContent=Ce(r.createdAt);const $=document.createElement("dt");$.textContent="updated";const Y=document.createElement("dd");if(Y.textContent=Ce(r.updatedAt),z.appendChild(A),z.appendChild(D),z.appendChild($),z.appendChild(Y),U.appendChild(z),t.appendChild(U),o){const E=document.createElement("div");E.className="info-section info-danger";const k=document.createElement("button");k.className="info-delete-node",k.textContent="Delete node",k.addEventListener("click",()=>{o.onDeleteNode(u),h()}),E.appendChild(k),t.appendChild(E)}}function G(u,x){const r=new Set(u),C=x.nodes.filter(A=>r.has(A.id));if(C.length===0)return;const S=x.edges.filter(A=>r.has(A.sourceId)&&r.has(A.targetId));t.innerHTML="",t.classList.remove("hidden"),d&&t.classList.add("info-panel-maximized"),t.appendChild(M());const O=document.createElement("div");O.className="info-header";const j=document.createElement("h3");j.className="info-label",j.textContent=`${C.length} nodes selected`,O.appendChild(j);const F=document.createElement("div");F.style.cssText="display:flex;flex-wrap:wrap;gap:4px;margin-top:6px";const W=new Map;for(const A of C)W.set(A.type,(W.get(A.type)??0)+1);for(const[A,D]of W){const $=document.createElement("span");$.className="info-type-badge",$.style.backgroundColor=Z(A),$.textContent=D>1?`${A} (${D})`:A,F.appendChild($)}O.appendChild(F),t.appendChild(O);const X=ae("Selected Nodes"),U=document.createElement("ul");U.className="info-connections";for(const A of C){const D=document.createElement("li");D.className="info-connection",s&&(D.classList.add("info-connection-link"),D.addEventListener("click",()=>{m(A.id)}));const $=document.createElement("span");$.className="info-target-dot",$.style.backgroundColor=Z(A.type);const Y=document.createElement("span");Y.className="info-target",Y.textContent=se(A);const E=document.createElement("span");E.className="info-edge-type",E.textContent=A.type,D.appendChild($),D.appendChild(Y),D.appendChild(E),U.appendChild(D)}X.appendChild(U),t.appendChild(X);const z=ae(S.length>0?`Connections Between Selected (${S.length})`:"Connections Between Selected");if(S.length===0){const A=document.createElement("p");A.style.cssText="font-size:12px;color:var(--text-dim)",A.textContent="No direct connections between selected nodes",z.appendChild(A)}else{const A=document.createElement("ul");A.className="info-connections";for(const D of S){const $=x.nodes.find(w=>w.id===D.sourceId),Y=x.nodes.find(w=>w.id===D.targetId),E=$?se($):D.sourceId,k=Y?se(Y):D.targetId,n=document.createElement("li");if(n.className="info-connection",$){const w=document.createElement("span");w.className="info-target-dot",w.style.backgroundColor=Z($.type),n.appendChild(w)}const l=document.createElement("span");l.className="info-target",l.textContent=E;const p=document.createElement("span");p.className="info-arrow",p.textContent="→";const g=document.createElement("span");g.className="info-edge-type",g.textContent=D.type;const N=document.createElement("span");if(N.className="info-arrow",N.textContent="→",n.appendChild(l),n.appendChild(p),n.appendChild(g),n.appendChild(N),Y){const w=document.createElement("span");w.className="info-target-dot",w.style.backgroundColor=Z(Y.type),n.appendChild(w)}const b=document.createElement("span");b.className="info-target",b.textContent=k,n.appendChild(b);const B=Object.keys(D.properties);if(B.length>0){const w=document.createElement("div");w.className="info-edge-props";for(const H of B){const R=document.createElement("span");R.className="info-edge-prop",R.textContent=`${H}: ${re(D.properties[H])}`,w.appendChild(R)}n.appendChild(w)}A.appendChild(n)}z.appendChild(A)}t.appendChild(z)}return{show(u,x){if(f=x,u.length===1&&!v){const r=u[0];e[i]!==r&&(i<e.length-1&&(e=e.slice(0,i+1)),e.push(r),i=e.length-1)}u.length===1?I(u[0],x):u.length>1&&G(u,x)},hide:h,get visible(){return!t.classList.contains("hidden")}}}function ae(a){const o=document.createElement("div");o.className="info-section";const s=document.createElement("h4");return s.className="info-section-title",s.textContent=a,o.appendChild(s),o}function ze(a){if(Array.isArray(a)){const s=document.createElement("div");s.className="info-array";for(const t of a){const d=document.createElement("span");d.className="info-tag",d.textContent=String(t),s.appendChild(d)}return s}if(a!==null&&typeof a=="object"){const s=document.createElement("pre");return s.className="info-json",s.textContent=JSON.stringify(a,null,2),s}const o=document.createElement("span");return o.className="info-value",o.textContent=String(a??""),o}function re(a){return Array.isArray(a)?a.map(String).join(", "):a!==null&&typeof a=="object"?JSON.stringify(a):String(a??"")}function $e(a){const o=a.trim();if(o==="true")return!0;if(o==="false")return!1;if(o!==""&&!isNaN(Number(o)))return Number(o);if(o.startsWith("[")&&o.endsWith("]")||o.startsWith("{")&&o.endsWith("}"))try{return JSON.parse(o)}catch{return a}return a}function Ce(a){try{return new Date(a).toLocaleString()}catch{return a}}function Ee(a){for(const o of Object.values(a.properties))if(typeof o=="string")return o;return a.id}function xe(a,o){const s=o.toLowerCase();if(Ee(a).toLowerCase().includes(s)||a.type.toLowerCase().includes(s))return!0;for(const t of Object.values(a.properties))if(typeof t=="string"&&t.toLowerCase().includes(s))return!0;return!1}function He(a){let o=null,s=null,t=null,d=new Set,e=null;const i=document.createElement("div");i.className="search-overlay hidden";const v=document.createElement("div");v.className="search-input-wrap";const f=document.createElement("input");f.className="search-input",f.type="text",f.placeholder="Search nodes...",f.setAttribute("autocomplete","off"),f.setAttribute("spellcheck","false");const h=document.createElement("kbd");h.className="search-kbd",h.textContent="/",v.appendChild(f),v.appendChild(h);const m=document.createElement("ul");m.className="search-results hidden";const c=document.createElement("div");c.className="type-chips",i.appendChild(v),i.appendChild(m),i.appendChild(c),a.appendChild(i);function y(){if(c.innerHTML="",!o)return;const u=new Map;for(const r of o.nodes)u.set(r.type,(u.get(r.type)??0)+1);const x=[...u.keys()].sort();d=new Set;for(const r of x){const C=document.createElement("button");C.className="type-chip",C.dataset.type=r;const S=document.createElement("span");S.className="type-chip-dot",S.style.backgroundColor=Z(r);const O=document.createElement("span");O.textContent=`${r} (${u.get(r)})`,C.appendChild(S),C.appendChild(O),C.addEventListener("click",()=>{d.has(r)?(d.delete(r),C.classList.remove("active")):(d.add(r),C.classList.add("active")),I()}),c.appendChild(C)}}function M(){if(!o)return null;const u=f.value.trim(),x=d.size===0,r=u.length===0;if(r&&x)return null;const C=new Set;for(const S of o.nodes)!x&&!d.has(S.type)||(r||xe(S,u))&&C.add(S.id);return C}function I(){const u=M();s==null||s(u),G()}function G(){m.innerHTML="";const u=f.value.trim();if(!o||u.length===0){m.classList.add("hidden");return}const x=d.size===0,r=[];for(const C of o.nodes)if(!(!x&&!d.has(C.type))&&xe(C,u)&&(r.push(C),r.length>=8))break;if(r.length===0){m.classList.add("hidden");return}for(const C of r){const S=document.createElement("li");S.className="search-result-item";const O=document.createElement("span");O.className="search-result-dot",O.style.backgroundColor=Z(C.type);const j=document.createElement("span");j.className="search-result-label";const F=Ee(C);j.textContent=F.length>36?F.slice(0,34)+"...":F;const W=document.createElement("span");W.className="search-result-type",W.textContent=C.type,S.appendChild(O),S.appendChild(j),S.appendChild(W),S.addEventListener("click",()=>{t==null||t(C.id),f.value="",m.classList.add("hidden"),I()}),m.appendChild(S)}m.classList.remove("hidden")}return f.addEventListener("input",()=>{e&&clearTimeout(e),e=setTimeout(I,150)}),f.addEventListener("keydown",u=>{if(u.key==="Escape")f.value="",f.blur(),m.classList.add("hidden"),I();else if(u.key==="Enter"){const x=m.querySelector(".search-result-item");x==null||x.click()}}),document.addEventListener("click",u=>{i.contains(u.target)||m.classList.add("hidden")}),f.addEventListener("focus",()=>h.classList.add("hidden")),f.addEventListener("blur",()=>{f.value.length===0&&h.classList.remove("hidden")}),{setOntologyData(u){o=u,f.value="",m.classList.add("hidden"),o&&o.nodes.length>0?(i.classList.remove("hidden"),y()):i.classList.add("hidden")},onFilterChange(u){s=u},onNodeSelect(u){t=u},clear(){f.value="",m.classList.add("hidden"),d.clear(),s==null||s(null)},focus(){f.focus()}}}let Q="",L=null;async function Fe(){const a=document.getElementById("canvas-container"),o=window.matchMedia("(prefers-color-scheme: dark)"),t=localStorage.getItem("backpack-theme")??(o.matches?"dark":"light");document.documentElement.setAttribute("data-theme",t);const d=document.createElement("button");d.className="theme-toggle",d.textContent=t==="light"?"☾":"☼",d.title="Toggle light/dark mode",d.addEventListener("click",()=>{const y=document.documentElement.getAttribute("data-theme")==="light"?"dark":"light";document.documentElement.setAttribute("data-theme",y),localStorage.setItem("backpack-theme",y),d.textContent=y==="light"?"☾":"☼"}),a.appendChild(d);async function e(){if(!Q||!L)return;L.metadata.updatedAt=new Date().toISOString(),await Ne(Q,L),i.loadGraph(L),f.setOntologyData(L);const c=await ie();h.setSummaries(c)}let i;const v=Re(a,{onUpdateNode(c,y){if(!L)return;const M=L.nodes.find(I=>I.id===c);M&&(M.properties={...M.properties,...y},M.updatedAt=new Date().toISOString(),e().then(()=>v.show([c],L)))},onChangeNodeType(c,y){if(!L)return;const M=L.nodes.find(I=>I.id===c);M&&(M.type=y,M.updatedAt=new Date().toISOString(),e().then(()=>v.show([c],L)))},onDeleteNode(c){L&&(L.nodes=L.nodes.filter(y=>y.id!==c),L.edges=L.edges.filter(y=>y.sourceId!==c&&y.targetId!==c),e())},onDeleteEdge(c){var M;if(!L)return;const y=(M=L.edges.find(I=>I.id===c))==null?void 0:M.sourceId;L.edges=L.edges.filter(I=>I.id!==c),e().then(()=>{y&&L&&v.show([y],L)})},onAddProperty(c,y,M){if(!L)return;const I=L.nodes.find(G=>G.id===c);I&&(I.properties[y]=M,I.updatedAt=new Date().toISOString(),e().then(()=>v.show([c],L)))}},c=>{i.panToNode(c)});i=Pe(a,c=>{c&&c.length>0&&L?v.show(c,L):v.hide()});const f=He(a);f.onFilterChange(c=>{i.setFilteredNodeIds(c)}),f.onNodeSelect(c=>{i.panToNode(c),L&&v.show([c],L)});const h=Me(document.getElementById("sidebar"),{onSelect:async c=>{Q=c,h.setActive(c),v.hide(),f.clear(),L=await le(c),i.loadGraph(L),f.setOntologyData(L)},onRename:async(c,y)=>{await Se(c,y),Q===c&&(Q=y);const M=await ie();h.setSummaries(M),h.setActive(Q),Q===y&&(L=await le(y),i.loadGraph(L),f.setOntologyData(L))}}),m=await ie();h.setSummaries(m),m.length>0&&(Q=m[0].name,h.setActive(Q),L=await le(Q),i.loadGraph(L),f.setOntologyData(L)),document.addEventListener("keydown",c=>{c.target instanceof HTMLInputElement||c.target instanceof HTMLTextAreaElement||(c.key==="/"||c.key==="k"&&(c.metaKey||c.ctrlKey))&&(c.preventDefault(),f.focus())})}Fe();
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Backpack Ontology Viewer</title>
7
- <script type="module" crossorigin src="/assets/index-CCGK0wTJ.js"></script>
8
- <link rel="stylesheet" crossorigin href="/assets/index-Bm7EACmJ.css">
7
+ <script type="module" crossorigin src="/assets/index-Ev20LdMk.js"></script>
8
+ <link rel="stylesheet" crossorigin href="/assets/index-CLyb9OCm.css">
9
9
  </head>
10
10
  <body>
11
11
  <div id="app">
@@ -0,0 +1,7 @@
1
+ import type { OntologyData } from "backpack-ontology";
2
+ export declare function initCanvas(container: HTMLElement, onNodeClick?: (nodeIds: string[] | null) => void): {
3
+ loadGraph(data: OntologyData): void;
4
+ setFilteredNodeIds(ids: Set<string> | null): void;
5
+ panToNode(nodeId: string): void;
6
+ destroy(): void;
7
+ };
package/dist/canvas.js ADDED
@@ -0,0 +1,442 @@
1
+ import { createLayout, tick } from "./layout";
2
+ import { getColor } from "./colors";
3
+ /** Read a CSS custom property from :root. */
4
+ function cssVar(name) {
5
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
6
+ }
7
+ const NODE_RADIUS = 20;
8
+ const ALPHA_MIN = 0.001;
9
+ export function initCanvas(container, onNodeClick) {
10
+ const canvas = container.querySelector("canvas");
11
+ const ctx = canvas.getContext("2d");
12
+ const dpr = window.devicePixelRatio || 1;
13
+ let camera = { x: 0, y: 0, scale: 1 };
14
+ let state = null;
15
+ let alpha = 1;
16
+ let animFrame = 0;
17
+ let selectedNodeIds = new Set();
18
+ let filteredNodeIds = null; // null = no filter (show all)
19
+ // Pan animation state
20
+ let panTarget = null;
21
+ let panStart = null;
22
+ const PAN_DURATION = 300;
23
+ // --- Sizing ---
24
+ function resize() {
25
+ canvas.width = canvas.clientWidth * dpr;
26
+ canvas.height = canvas.clientHeight * dpr;
27
+ render();
28
+ }
29
+ const observer = new ResizeObserver(resize);
30
+ observer.observe(container);
31
+ resize();
32
+ // --- Coordinate transforms ---
33
+ function screenToWorld(sx, sy) {
34
+ return [
35
+ sx / camera.scale + camera.x,
36
+ sy / camera.scale + camera.y,
37
+ ];
38
+ }
39
+ // --- Hit testing ---
40
+ function nodeAtScreen(sx, sy) {
41
+ if (!state)
42
+ return null;
43
+ const [wx, wy] = screenToWorld(sx, sy);
44
+ // Iterate in reverse so topmost (last drawn) nodes are hit first
45
+ for (let i = state.nodes.length - 1; i >= 0; i--) {
46
+ const node = state.nodes[i];
47
+ const dx = wx - node.x;
48
+ const dy = wy - node.y;
49
+ if (dx * dx + dy * dy <= NODE_RADIUS * NODE_RADIUS) {
50
+ return node;
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+ // --- Rendering ---
56
+ function render() {
57
+ if (!state) {
58
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
59
+ return;
60
+ }
61
+ // Read theme colors from CSS variables each frame
62
+ const edgeColor = cssVar("--canvas-edge");
63
+ const edgeHighlight = cssVar("--canvas-edge-highlight");
64
+ const edgeDimColor = cssVar("--canvas-edge-dim");
65
+ const edgeLabel = cssVar("--canvas-edge-label");
66
+ const edgeLabelHighlight = cssVar("--canvas-edge-label-highlight");
67
+ const edgeLabelDim = cssVar("--canvas-edge-label-dim");
68
+ const arrowColor = cssVar("--canvas-arrow");
69
+ const arrowHighlight = cssVar("--canvas-arrow-highlight");
70
+ const nodeLabel = cssVar("--canvas-node-label");
71
+ const nodeLabelDim = cssVar("--canvas-node-label-dim");
72
+ const typeBadge = cssVar("--canvas-type-badge");
73
+ const typeBadgeDim = cssVar("--canvas-type-badge-dim");
74
+ const selectionBorder = cssVar("--canvas-selection-border");
75
+ const nodeBorder = cssVar("--canvas-node-border");
76
+ ctx.save();
77
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
78
+ ctx.clearRect(0, 0, canvas.clientWidth, canvas.clientHeight);
79
+ ctx.save();
80
+ ctx.translate(-camera.x * camera.scale, -camera.y * camera.scale);
81
+ ctx.scale(camera.scale, camera.scale);
82
+ // Draw edges
83
+ for (const edge of state.edges) {
84
+ const source = state.nodeMap.get(edge.sourceId);
85
+ const target = state.nodeMap.get(edge.targetId);
86
+ if (!source || !target)
87
+ continue;
88
+ const sourceMatch = filteredNodeIds === null || filteredNodeIds.has(edge.sourceId);
89
+ const targetMatch = filteredNodeIds === null || filteredNodeIds.has(edge.targetId);
90
+ const bothMatch = sourceMatch && targetMatch;
91
+ // Hide edges where neither endpoint matches the filter
92
+ if (filteredNodeIds !== null && !sourceMatch && !targetMatch)
93
+ continue;
94
+ const isConnected = selectedNodeIds.size > 0 &&
95
+ (selectedNodeIds.has(edge.sourceId) || selectedNodeIds.has(edge.targetId));
96
+ const highlighted = isConnected || (filteredNodeIds !== null && bothMatch);
97
+ const edgeDimmed = filteredNodeIds !== null && !bothMatch;
98
+ // Self-loop
99
+ if (edge.sourceId === edge.targetId) {
100
+ drawSelfLoop(source, edge.type, highlighted, edgeColor, edgeHighlight, edgeLabel, edgeLabelHighlight);
101
+ continue;
102
+ }
103
+ // Line
104
+ ctx.beginPath();
105
+ ctx.moveTo(source.x, source.y);
106
+ ctx.lineTo(target.x, target.y);
107
+ ctx.strokeStyle = highlighted
108
+ ? edgeHighlight
109
+ : edgeDimmed
110
+ ? edgeDimColor
111
+ : edgeColor;
112
+ ctx.lineWidth = highlighted ? 2.5 : 1.5;
113
+ ctx.stroke();
114
+ // Arrowhead
115
+ drawArrowhead(source.x, source.y, target.x, target.y, highlighted, arrowColor, arrowHighlight);
116
+ // Edge label at midpoint
117
+ const mx = (source.x + target.x) / 2;
118
+ const my = (source.y + target.y) / 2;
119
+ ctx.fillStyle = highlighted
120
+ ? edgeLabelHighlight
121
+ : edgeDimmed
122
+ ? edgeLabelDim
123
+ : edgeLabel;
124
+ ctx.font = "9px system-ui, sans-serif";
125
+ ctx.textAlign = "center";
126
+ ctx.textBaseline = "bottom";
127
+ ctx.fillText(edge.type, mx, my - 4);
128
+ }
129
+ // Draw nodes
130
+ for (const node of state.nodes) {
131
+ const color = getColor(node.type);
132
+ const isSelected = selectedNodeIds.has(node.id);
133
+ const isNeighbor = selectedNodeIds.size > 0 &&
134
+ state.edges.some((e) => (selectedNodeIds.has(e.sourceId) && e.targetId === node.id) ||
135
+ (selectedNodeIds.has(e.targetId) && e.sourceId === node.id));
136
+ const filteredOut = filteredNodeIds !== null && !filteredNodeIds.has(node.id);
137
+ const dimmed = filteredOut ||
138
+ (selectedNodeIds.size > 0 && !isSelected && !isNeighbor);
139
+ // Glow for selected node
140
+ if (isSelected) {
141
+ ctx.save();
142
+ ctx.shadowColor = color;
143
+ ctx.shadowBlur = 20;
144
+ ctx.beginPath();
145
+ ctx.arc(node.x, node.y, NODE_RADIUS + 3, 0, Math.PI * 2);
146
+ ctx.fillStyle = color;
147
+ ctx.globalAlpha = 0.3;
148
+ ctx.fill();
149
+ ctx.restore();
150
+ }
151
+ // Circle
152
+ ctx.beginPath();
153
+ ctx.arc(node.x, node.y, NODE_RADIUS, 0, Math.PI * 2);
154
+ ctx.fillStyle = color;
155
+ ctx.globalAlpha = filteredOut ? 0.1 : dimmed ? 0.3 : 1;
156
+ ctx.fill();
157
+ ctx.strokeStyle = isSelected ? selectionBorder : nodeBorder;
158
+ ctx.lineWidth = isSelected ? 3 : 1.5;
159
+ ctx.stroke();
160
+ // Label below
161
+ const label = node.label.length > 24 ? node.label.slice(0, 22) + "..." : node.label;
162
+ ctx.fillStyle = dimmed ? nodeLabelDim : nodeLabel;
163
+ ctx.font = "11px system-ui, sans-serif";
164
+ ctx.textAlign = "center";
165
+ ctx.textBaseline = "top";
166
+ ctx.fillText(label, node.x, node.y + NODE_RADIUS + 4);
167
+ // Type badge above
168
+ ctx.fillStyle = dimmed ? typeBadgeDim : typeBadge;
169
+ ctx.font = "9px system-ui, sans-serif";
170
+ ctx.textBaseline = "bottom";
171
+ ctx.fillText(node.type, node.x, node.y - NODE_RADIUS - 3);
172
+ ctx.globalAlpha = 1;
173
+ }
174
+ ctx.restore();
175
+ ctx.restore();
176
+ }
177
+ function drawArrowhead(sx, sy, tx, ty, highlighted, arrowColor, arrowHighlight) {
178
+ const angle = Math.atan2(ty - sy, tx - sx);
179
+ const tipX = tx - Math.cos(angle) * NODE_RADIUS;
180
+ const tipY = ty - Math.sin(angle) * NODE_RADIUS;
181
+ const size = 8;
182
+ ctx.beginPath();
183
+ ctx.moveTo(tipX, tipY);
184
+ ctx.lineTo(tipX - size * Math.cos(angle - 0.4), tipY - size * Math.sin(angle - 0.4));
185
+ ctx.lineTo(tipX - size * Math.cos(angle + 0.4), tipY - size * Math.sin(angle + 0.4));
186
+ ctx.closePath();
187
+ ctx.fillStyle = highlighted ? arrowHighlight : arrowColor;
188
+ ctx.fill();
189
+ }
190
+ function drawSelfLoop(node, type, highlighted, edgeColor, edgeHighlight, labelColor, labelHighlight) {
191
+ const cx = node.x + NODE_RADIUS + 15;
192
+ const cy = node.y - NODE_RADIUS - 15;
193
+ ctx.beginPath();
194
+ ctx.arc(cx, cy, 15, 0, Math.PI * 2);
195
+ ctx.strokeStyle = highlighted ? edgeHighlight : edgeColor;
196
+ ctx.lineWidth = highlighted ? 2.5 : 1.5;
197
+ ctx.stroke();
198
+ ctx.fillStyle = highlighted ? labelHighlight : labelColor;
199
+ ctx.font = "9px system-ui, sans-serif";
200
+ ctx.textAlign = "center";
201
+ ctx.fillText(type, cx, cy - 18);
202
+ }
203
+ // --- Simulation loop ---
204
+ function animatePan() {
205
+ if (!panTarget || !panStart)
206
+ return;
207
+ const elapsed = performance.now() - panStart.time;
208
+ const t = Math.min(elapsed / PAN_DURATION, 1);
209
+ // Ease out cubic
210
+ const ease = 1 - Math.pow(1 - t, 3);
211
+ camera.x = panStart.x + (panTarget.x - panStart.x) * ease;
212
+ camera.y = panStart.y + (panTarget.y - panStart.y) * ease;
213
+ render();
214
+ if (t < 1) {
215
+ requestAnimationFrame(animatePan);
216
+ }
217
+ else {
218
+ panTarget = null;
219
+ panStart = null;
220
+ }
221
+ }
222
+ function simulate() {
223
+ if (!state || alpha < ALPHA_MIN)
224
+ return;
225
+ alpha = tick(state, alpha);
226
+ render();
227
+ animFrame = requestAnimationFrame(simulate);
228
+ }
229
+ // --- Interaction: Pan + Click ---
230
+ let dragging = false;
231
+ let didDrag = false;
232
+ let lastX = 0;
233
+ let lastY = 0;
234
+ canvas.addEventListener("mousedown", (e) => {
235
+ dragging = true;
236
+ didDrag = false;
237
+ lastX = e.clientX;
238
+ lastY = e.clientY;
239
+ });
240
+ canvas.addEventListener("mousemove", (e) => {
241
+ if (!dragging)
242
+ return;
243
+ const dx = e.clientX - lastX;
244
+ const dy = e.clientY - lastY;
245
+ if (Math.abs(dx) > 2 || Math.abs(dy) > 2)
246
+ didDrag = true;
247
+ camera.x -= dx / camera.scale;
248
+ camera.y -= dy / camera.scale;
249
+ lastX = e.clientX;
250
+ lastY = e.clientY;
251
+ render();
252
+ });
253
+ canvas.addEventListener("mouseup", (e) => {
254
+ dragging = false;
255
+ if (didDrag)
256
+ return;
257
+ // Click — hit test for node selection
258
+ const rect = canvas.getBoundingClientRect();
259
+ const mx = e.clientX - rect.left;
260
+ const my = e.clientY - rect.top;
261
+ const hit = nodeAtScreen(mx, my);
262
+ const multiSelect = e.ctrlKey || e.metaKey;
263
+ if (hit) {
264
+ if (multiSelect) {
265
+ // Toggle node in/out of multi-selection
266
+ if (selectedNodeIds.has(hit.id)) {
267
+ selectedNodeIds.delete(hit.id);
268
+ }
269
+ else {
270
+ selectedNodeIds.add(hit.id);
271
+ }
272
+ }
273
+ else {
274
+ // Single click — toggle if already the only selection, otherwise replace
275
+ if (selectedNodeIds.size === 1 && selectedNodeIds.has(hit.id)) {
276
+ selectedNodeIds.clear();
277
+ }
278
+ else {
279
+ selectedNodeIds.clear();
280
+ selectedNodeIds.add(hit.id);
281
+ }
282
+ }
283
+ const ids = [...selectedNodeIds];
284
+ onNodeClick?.(ids.length > 0 ? ids : null);
285
+ }
286
+ else {
287
+ selectedNodeIds.clear();
288
+ onNodeClick?.(null);
289
+ }
290
+ render();
291
+ });
292
+ canvas.addEventListener("mouseleave", () => {
293
+ dragging = false;
294
+ });
295
+ // --- Interaction: Zoom (wheel + pinch) ---
296
+ canvas.addEventListener("wheel", (e) => {
297
+ e.preventDefault();
298
+ const rect = canvas.getBoundingClientRect();
299
+ const mx = e.clientX - rect.left;
300
+ const my = e.clientY - rect.top;
301
+ const [wx, wy] = screenToWorld(mx, my);
302
+ const factor = e.ctrlKey
303
+ ? 1 - e.deltaY * 0.01
304
+ : e.deltaY > 0
305
+ ? 0.9
306
+ : 1.1;
307
+ camera.scale = Math.max(0.05, Math.min(10, camera.scale * factor));
308
+ camera.x = wx - mx / camera.scale;
309
+ camera.y = wy - my / camera.scale;
310
+ render();
311
+ }, { passive: false });
312
+ // --- Interaction: Touch (pinch zoom + drag) ---
313
+ let touches = [];
314
+ let initialPinchDist = 0;
315
+ let initialPinchScale = 1;
316
+ canvas.addEventListener("touchstart", (e) => {
317
+ e.preventDefault();
318
+ touches = Array.from(e.touches);
319
+ if (touches.length === 2) {
320
+ initialPinchDist = touchDistance(touches[0], touches[1]);
321
+ initialPinchScale = camera.scale;
322
+ }
323
+ else if (touches.length === 1) {
324
+ lastX = touches[0].clientX;
325
+ lastY = touches[0].clientY;
326
+ }
327
+ }, { passive: false });
328
+ canvas.addEventListener("touchmove", (e) => {
329
+ e.preventDefault();
330
+ const current = Array.from(e.touches);
331
+ if (current.length === 2 && touches.length === 2) {
332
+ const dist = touchDistance(current[0], current[1]);
333
+ const ratio = dist / initialPinchDist;
334
+ camera.scale = Math.max(0.05, Math.min(10, initialPinchScale * ratio));
335
+ render();
336
+ }
337
+ else if (current.length === 1) {
338
+ const dx = current[0].clientX - lastX;
339
+ const dy = current[0].clientY - lastY;
340
+ camera.x -= dx / camera.scale;
341
+ camera.y -= dy / camera.scale;
342
+ lastX = current[0].clientX;
343
+ lastY = current[0].clientY;
344
+ render();
345
+ }
346
+ touches = current;
347
+ }, { passive: false });
348
+ function touchDistance(a, b) {
349
+ const dx = a.clientX - b.clientX;
350
+ const dy = a.clientY - b.clientY;
351
+ return Math.sqrt(dx * dx + dy * dy);
352
+ }
353
+ // --- Zoom controls ---
354
+ const zoomControls = document.createElement("div");
355
+ zoomControls.className = "zoom-controls";
356
+ const zoomInBtn = document.createElement("button");
357
+ zoomInBtn.className = "zoom-btn";
358
+ zoomInBtn.textContent = "+";
359
+ zoomInBtn.title = "Zoom in";
360
+ zoomInBtn.addEventListener("click", () => {
361
+ const cx = canvas.clientWidth / 2;
362
+ const cy = canvas.clientHeight / 2;
363
+ const [wx, wy] = screenToWorld(cx, cy);
364
+ camera.scale = Math.min(10, camera.scale * 1.3);
365
+ camera.x = wx - cx / camera.scale;
366
+ camera.y = wy - cy / camera.scale;
367
+ render();
368
+ });
369
+ const zoomOutBtn = document.createElement("button");
370
+ zoomOutBtn.className = "zoom-btn";
371
+ zoomOutBtn.textContent = "\u2212";
372
+ zoomOutBtn.title = "Zoom out";
373
+ zoomOutBtn.addEventListener("click", () => {
374
+ const cx = canvas.clientWidth / 2;
375
+ const cy = canvas.clientHeight / 2;
376
+ const [wx, wy] = screenToWorld(cx, cy);
377
+ camera.scale = Math.max(0.05, camera.scale / 1.3);
378
+ camera.x = wx - cx / camera.scale;
379
+ camera.y = wy - cy / camera.scale;
380
+ render();
381
+ });
382
+ zoomControls.appendChild(zoomInBtn);
383
+ zoomControls.appendChild(zoomOutBtn);
384
+ container.appendChild(zoomControls);
385
+ // --- Public API ---
386
+ return {
387
+ loadGraph(data) {
388
+ cancelAnimationFrame(animFrame);
389
+ state = createLayout(data);
390
+ alpha = 1;
391
+ selectedNodeIds = new Set();
392
+ filteredNodeIds = null;
393
+ // Center camera on the graph
394
+ camera = { x: 0, y: 0, scale: 1 };
395
+ if (state.nodes.length > 0) {
396
+ let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
397
+ for (const n of state.nodes) {
398
+ if (n.x < minX)
399
+ minX = n.x;
400
+ if (n.y < minY)
401
+ minY = n.y;
402
+ if (n.x > maxX)
403
+ maxX = n.x;
404
+ if (n.y > maxY)
405
+ maxY = n.y;
406
+ }
407
+ const cx = (minX + maxX) / 2;
408
+ const cy = (minY + maxY) / 2;
409
+ const w = canvas.clientWidth;
410
+ const h = canvas.clientHeight;
411
+ camera.x = cx - w / 2;
412
+ camera.y = cy - h / 2;
413
+ }
414
+ simulate();
415
+ },
416
+ setFilteredNodeIds(ids) {
417
+ filteredNodeIds = ids;
418
+ render();
419
+ },
420
+ panToNode(nodeId) {
421
+ if (!state)
422
+ return;
423
+ const node = state.nodeMap.get(nodeId);
424
+ if (!node)
425
+ return;
426
+ selectedNodeIds = new Set([nodeId]);
427
+ onNodeClick?.([nodeId]);
428
+ const w = canvas.clientWidth;
429
+ const h = canvas.clientHeight;
430
+ panStart = { x: camera.x, y: camera.y, time: performance.now() };
431
+ panTarget = {
432
+ x: node.x - w / (2 * camera.scale),
433
+ y: node.y - h / (2 * camera.scale),
434
+ };
435
+ animatePan();
436
+ },
437
+ destroy() {
438
+ cancelAnimationFrame(animFrame);
439
+ observer.disconnect();
440
+ },
441
+ };
442
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Deterministic type → color mapping.
3
+ * Earth-tone accent palette on a neutral gray UI.
4
+ * These are the only warm colors in the interface —
5
+ * everything else is grayscale.
6
+ */
7
+ export declare function getColor(type: string): string;
package/dist/colors.js ADDED
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Deterministic type → color mapping.
3
+ * Earth-tone accent palette on a neutral gray UI.
4
+ * These are the only warm colors in the interface —
5
+ * everything else is grayscale.
6
+ */
7
+ const PALETTE = [
8
+ "#d4a27f", // warm tan
9
+ "#c17856", // terracotta
10
+ "#b07a5e", // sienna
11
+ "#d4956b", // burnt amber
12
+ "#a67c5a", // walnut
13
+ "#cc9e7c", // copper
14
+ "#c4866a", // clay
15
+ "#cb8e6c", // apricot
16
+ "#b8956e", // wheat
17
+ "#a88a70", // driftwood
18
+ "#d9b08c", // caramel
19
+ "#c4a882", // sand
20
+ "#e8b898", // peach
21
+ "#b5927a", // dusty rose
22
+ "#a8886e", // muted brown
23
+ "#d1a990", // blush tan
24
+ ];
25
+ const cache = new Map();
26
+ export function getColor(type) {
27
+ const cached = cache.get(type);
28
+ if (cached)
29
+ return cached;
30
+ let hash = 0;
31
+ for (let i = 0; i < type.length; i++) {
32
+ hash = ((hash << 5) - hash + type.charCodeAt(i)) | 0;
33
+ }
34
+ const color = PALETTE[Math.abs(hash) % PALETTE.length];
35
+ cache.set(type, color);
36
+ return color;
37
+ }