@gsteja2307/orbit-3d-explorer 0.0.0-stage → 2.0.0
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/CHANGELOG.md +72 -0
- package/README.md +99 -2
- package/cli.mjs +2 -0
- package/dist/app.js +63 -0
- package/dist/graph.js +16 -0
- package/dist/index.html +7 -0
- package/dist/model.js +67 -0
- package/dist/sample.json +1019 -0
- package/dist/store.js +12 -0
- package/dist/style.css +1 -0
- package/dist/vendor/LICENSE +21 -0
- package/dist/vendor/OrbitControls.js +1523 -0
- package/dist/vendor/three.module.js +54155 -0
- package/package.json +50 -4
- package/scripts/vendor.mjs +12 -0
- package/server.mjs +23 -0
- package/tests/model.test.mjs +24 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 2.0.0 — 2026-10-28
|
|
4
|
+
|
|
5
|
+
Orbit evolves from a fixed employee-directory demo into an editable, domain-neutral relationship workspace.
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Generic entity types, custom text attributes, tags, notes, status, deadlines, progress, and HTTP/HTTPS resource links.
|
|
10
|
+
- Entity and directed relationship CRUD, with role labels and assignment allocation.
|
|
11
|
+
- Browser-local persistence, ten undo snapshots, 200-event local activity history, and stale-tab revision detection.
|
|
12
|
+
- Saved views, bookmarks, type/department/status filters, and sortable tables.
|
|
13
|
+
- Type-cluster, sphere, and 2D layouts, PNG geometry export, and label controls.
|
|
14
|
+
- One/two-hop neighborhoods and shortest undirected relationship paths.
|
|
15
|
+
- Composition, connectivity hubs, at-risk/disconnected records, and capacity insights.
|
|
16
|
+
- Validated JSON import preview, merge/replace, V1 migration, graph/CSV export, and recovery backup downloads.
|
|
17
|
+
- Generic starter template, workspace rename, empty workspace creation, and sample reset.
|
|
18
|
+
- Framework-independent model API at @gsteja2307/orbit-3d-explorer/model.
|
|
19
|
+
- Regression tests and GitHub Actions build checks.
|
|
20
|
+
- Configurable PORT, loopback serving, method restrictions, malformed-path handling, and response headers.
|
|
21
|
+
|
|
22
|
+
### Breaking changes
|
|
23
|
+
|
|
24
|
+
Data uses schemaVersion 2 with nodes/edges instead of people/projects/applications. Import V1 exports through Data management. V2 exports are not backward compatible. The inspector now displays ownership, reporting, assignments, and application connections as explicit typed relationships.
|
|
25
|
+
|
|
26
|
+
### Validation
|
|
27
|
+
|
|
28
|
+
Build and 16 automated tests passed: graph validation, reporting cycles, paths, merge/delete, allocations, HTML/CSV escaping, persistence, undo, quota failures, stale revisions, and V1 migration. Browser checks verified custom entities/fields, persistence after reload, relationship creation, undo, path finding, and insights.
|
|
29
|
+
|
|
30
|
+
### Production boundaries
|
|
31
|
+
|
|
32
|
+
This is a local workspace release, not a production enterprise service. SSO, server-enforced RBAC, shared storage, tenant isolation, integrations, transactional collaboration, and compliance audit storage remain unimplemented. Identity-provider selection is deferred. Activity is not tamper-proof. Import caps: 500 nodes, 2,500 edges, 2 MB. Recovery import restores graph data only; PNG excludes HTML labels. Licensing remains UNLICENSED.
|
|
33
|
+
|
|
34
|
+
## 1.0.0 — 2026-10-28
|
|
35
|
+
|
|
36
|
+
Initial release of Orbit 3D Explorer: an interactive Three.js relationship graph with a fictional organization dataset demonstrating people, projects, and applications.
|
|
37
|
+
|
|
38
|
+
### Features
|
|
39
|
+
|
|
40
|
+
- Interactive 3D graph with orbit, zoom, automatic rotation, and a 2D view.
|
|
41
|
+
- Clickable nodes and highlighted connections with a focused neighborhood view.
|
|
42
|
+
- Employee profiles with managers, direct reports, skills, collaborators, and project-specific roles and allocation.
|
|
43
|
+
- Project details with progress, owners, teams, and connected application IDs.
|
|
44
|
+
- Application registry details with owners, environments, and associated projects.
|
|
45
|
+
- Search by name, skill, employee ID, or application ID; department and node-type filters.
|
|
46
|
+
- Alternative directory view and JSON export.
|
|
47
|
+
- Local npm commands and the `orbit-3d` executable for launching the demo through npx.
|
|
48
|
+
- Bundled Three.js browser assets, with Three.js pinned to 0.169.0.
|
|
49
|
+
|
|
50
|
+
### Quick start
|
|
51
|
+
|
|
52
|
+
```sh
|
|
53
|
+
npx @gsteja2307/orbit-3d-explorer@1.0.0
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Open http://127.0.0.1:4173. Requires Node.js 18+ and a WebGL-capable browser. Stop any existing server on port 4173 before launching.
|
|
57
|
+
|
|
58
|
+
### Validation
|
|
59
|
+
|
|
60
|
+
- Build and JavaScript syntax checks passed.
|
|
61
|
+
- Browser checks confirmed graph rendering, employee-to-project navigation, project-to-application navigation, and app-ID search.
|
|
62
|
+
- npm package contents inspected before publication; credentials and node_modules are excluded.
|
|
63
|
+
|
|
64
|
+
### Current limitations
|
|
65
|
+
|
|
66
|
+
- This release is a runnable demo application, not a reusable graph-component API. Other domains require adapting the data model, labels, and detail rendering in `dist/app.js`.
|
|
67
|
+
- Includes 16 fictional people, 6 projects, and 8 applications. No real HR or application integration is configured.
|
|
68
|
+
- No database, authentication, persistent editing, or shared workspace backend.
|
|
69
|
+
- Application IDs open registry details; external application launch URLs are not configured.
|
|
70
|
+
- Development changes require a browser refresh; no hot module replacement is provided.
|
|
71
|
+
- Project licensing is currently UNLICENSED; the bundled Three.js dependency retains its own license.
|
|
72
|
+
|
package/README.md
CHANGED
|
@@ -1,3 +1,100 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Orbit 3D Explorer — V2
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Editable 3D relationship workspace for people, projects, applications, services, assets, and other connected records.
|
|
4
|
+
|
|
5
|
+
**This is a local workspace application, not a production multi-user enterprise service.** Identity-provider selection is deferred; SSO, backend authorization, shared storage, and tenant isolation are not implemented.
|
|
6
|
+
|
|
7
|
+
## Run
|
|
8
|
+
|
|
9
|
+
Requires Node.js 18+ and a modern browser. WebGL is required for 3D; the directory remains usable if WebGL initialization fails.
|
|
10
|
+
|
|
11
|
+
~~~sh
|
|
12
|
+
npm ci
|
|
13
|
+
npm run dev
|
|
14
|
+
~~~
|
|
15
|
+
|
|
16
|
+
Or, once V2 is published:
|
|
17
|
+
|
|
18
|
+
~~~sh
|
|
19
|
+
npx @gsteja2307/orbit-3d-explorer@2.0.0
|
|
20
|
+
~~~
|
|
21
|
+
|
|
22
|
+
Open http://127.0.0.1:4173. Stop with Ctrl+C. The server binds only to loopback. Set the PORT environment variable to choose another port.
|
|
23
|
+
|
|
24
|
+
Scripts: npm start serves the app; npm run dev watches server files; npm run build prepares assets and runs checks/tests; npm test runs regression tests. Browser changes require a refresh.
|
|
25
|
+
|
|
26
|
+
## Features
|
|
27
|
+
|
|
28
|
+
- Generic entity types and custom text fields, tags, status, notes, contact details, deadlines, progress, and resource URLs.
|
|
29
|
+
- Create/edit/delete entities and directed relationships. Deletion removes attached edges.
|
|
30
|
+
- Assignment roles and allocation; explicit manager, owner, project, and application relationships.
|
|
31
|
+
- Type-cluster, sphere, and 2D layouts; orbit, zoom, rotation, label controls, and geometry PNG export.
|
|
32
|
+
- Search across names, IDs, tags, email, notes, and custom fields. Type, department, status, and bookmark filters.
|
|
33
|
+
- Sortable directory, clickable inspector, and derived coworker lists.
|
|
34
|
+
- One/two-hop neighborhoods, shortest connection paths, and entity URL fragments.
|
|
35
|
+
- Saved filter views and bookmarks.
|
|
36
|
+
- Composition, connection hubs, risk/disconnected-record insights, and capacity totals.
|
|
37
|
+
- Validated JSON import preview with merge/replace; V1 migration; JSON/CSV exports.
|
|
38
|
+
- Local persistence, ten undo snapshots, 200 activity events, and stale-tab revision checks.
|
|
39
|
+
- Generic starter template, workspace rename, empty workspace, and sample reset.
|
|
40
|
+
|
|
41
|
+
## Data model
|
|
42
|
+
|
|
43
|
+
~~~json
|
|
44
|
+
{
|
|
45
|
+
"schemaVersion": 2,
|
|
46
|
+
"name": "Service landscape",
|
|
47
|
+
"nodes": [
|
|
48
|
+
{"id":"api","name":"Customer API","type":"service","tags":["critical"],"attributes":{"Tier":"1"}},
|
|
49
|
+
{"id":"db","name":"Customer database","type":"database","tags":[]}
|
|
50
|
+
],
|
|
51
|
+
"edges": [
|
|
52
|
+
{"id":"api-db","source":"api","target":"db","kind":"depends_on","label":"Primary storage"}
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
~~~
|
|
56
|
+
|
|
57
|
+
Entity IDs and edge IDs must be unique within their collections. IDs start with a letter or digit and may contain letters, digits, dots, hyphens, underscores, or colons. Required entity fields: id, name, type, tags (array). Optional: title, department, status, email, location, url, notes, externalId, progress (0–100), due (YYYY-MM-DD), attributes (string-to-string object).
|
|
58
|
+
|
|
59
|
+
Edges require id, source, target, and kind. Optional label describes the relationship. Allocation (0–100) is supported only on works_on edges, summed by source. reports_to permits one manager per source and rejects cycles. Other relationship types are generic strings. Path finding ignores direction; the inspector displays direction.
|
|
60
|
+
|
|
61
|
+
Imports reject duplicate IDs, duplicate source/target/type triples, missing endpoints, self-links, reporting cycles, invalid dates/allocations, and URLs outside HTTP/HTTPS. Limits: 500 entities, 2,500 edges, 2 MB per imported file. These are validation caps, not performance guarantees.
|
|
62
|
+
|
|
63
|
+
## Programmatic model API
|
|
64
|
+
|
|
65
|
+
~~~js
|
|
66
|
+
import { validateGraph, shortestPath, insights } from '@gsteja2307/orbit-3d-explorer/model';
|
|
67
|
+
const errors = validateGraph(graph);
|
|
68
|
+
if (errors.length) throw new Error(errors.join('\n'));
|
|
69
|
+
const route = shortestPath(graph, 'api', 'db');
|
|
70
|
+
const summary = insights(graph);
|
|
71
|
+
~~~
|
|
72
|
+
|
|
73
|
+
Framework-independent model utilities are available; this package does not expose a React component or server CRUD API.
|
|
74
|
+
|
|
75
|
+
## V1 migration
|
|
76
|
+
|
|
77
|
+
Export JSON from V1. Open Data management → Choose JSON file in V2. Review counts and choose merge or replace. People/projects/applications migrate to generic entities and reporting, ownership, assignment, and application relationships. V2 exports cannot be loaded into V1 unchanged.
|
|
78
|
+
|
|
79
|
+
## Storage and limitations
|
|
80
|
+
|
|
81
|
+
Data is in localStorage under orbit.workspace.v2 for the current browser origin, with no backend transmission. Clearing browser data removes it; changing the port changes the origin. Export backups regularly. Storage quotas may limit the graph and undo history; failed writes preserve the current in-memory graph.
|
|
82
|
+
|
|
83
|
+
Graph JSON restores records and relationships. Full local backups include views, bookmarks, snapshots, and history for inspection; importing one restores only its graph. Entity links require the same workspace data at the destination.
|
|
84
|
+
|
|
85
|
+
Activity history is editable client data, not a compliance audit trail. Revision checks reduce stale overwrites but are not transactional multi-user concurrency. PNG captures WebGL geometry, excluding HTML labels. The sample has 16 fictional people, 6 projects, and 8 applications.
|
|
86
|
+
|
|
87
|
+
Production rollout still needs identity integration, server-enforced RBAC, shared transactional storage, tenant boundaries, server audit logs, backup/retention policies, integrations, monitoring, deployment hardening, and load/accessibility testing.
|
|
88
|
+
|
|
89
|
+
## Source
|
|
90
|
+
|
|
91
|
+
- dist/app.js: workspace UI
|
|
92
|
+
- dist/model.js: validation, migration, algorithms, exports
|
|
93
|
+
- dist/store.js: persistence, undo, activity
|
|
94
|
+
- dist/graph.js: Three.js rendering
|
|
95
|
+
- dist/sample.json: fictional example
|
|
96
|
+
- tests/model.test.mjs: regression suite
|
|
97
|
+
- server.mjs: loopback static server
|
|
98
|
+
|
|
99
|
+
Project licensing remains UNLICENSED. Three.js retains its bundled MIT license. See [CHANGELOG.md](CHANGELOG.md).
|
|
100
|
+
|
package/cli.mjs
ADDED
package/dist/app.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {clone,escapeHTML as h,normalizeGraph,migrateV1,mergeGraphs,neighbors,shortestPath,insights,removeNode,csvExport,LIMITS} from './model.js';
|
|
2
|
+
import {WorkspaceStore,KEY} from './store.js';
|
|
3
|
+
import {GraphView,colorFor} from './graph.js';
|
|
4
|
+
const $=s=>document.querySelector(s), $$=s=>[...document.querySelectorAll(s)];
|
|
5
|
+
const seed=await fetch('./sample.json').then(r=>{if(!r.ok)throw Error('Sample data unavailable');return r.json()});
|
|
6
|
+
const store=new WorkspaceStore(localStorage,seed);
|
|
7
|
+
let selected=new URLSearchParams(location.hash.slice(1)).get('node')||'e2',page='graph',focusDepth=0,bookmarksOnly=false,path=[],sortKey='name',sortAsc=true,graphView=null,modalRevision=0;
|
|
8
|
+
const graph=()=>store.state.graph, find=id=>graph().nodes.find(n=>n.id===id);
|
|
9
|
+
const pretty=s=>String(s||'').replaceAll('_',' ');
|
|
10
|
+
const initial=n=>n.name.split(' ').map(s=>s[0]).slice(0,2).join('').toUpperCase();
|
|
11
|
+
function warn(s){$('#warning').hidden=!s;$('#warning').textContent=s;}
|
|
12
|
+
if(store.warning)warn(store.warning);
|
|
13
|
+
try{graphView=new GraphView($('#scene'),select);}catch(e){warn('3D rendering is unavailable in this browser. The directory and workspace tools still work.');page='directory';}
|
|
14
|
+
function toast(s){$('#toast').textContent=s;$('#toast').style.display='block';clearTimeout(toast.timer);toast.timer=setTimeout(()=>$('#toast').style.display='none',3500);}
|
|
15
|
+
function download(name,content,type='application/json'){const url=URL.createObjectURL(new Blob([content],{type}));const a=document.createElement('a');a.href=url;a.download=name;a.click();setTimeout(()=>URL.revokeObjectURL(url),1000);}
|
|
16
|
+
function commit(next,action){store.commit(next,action);path=[];rebuild();$('#saveStatus').textContent='Saved on this device';toast(action);}
|
|
17
|
+
function run(fn){try{fn()}catch(e){toast(e.message);}}
|
|
18
|
+
function option(value,label,chosen){return '<option value="'+h(value)+'" '+(value===chosen?'selected':'')+'>'+h(label)+'</option>';}
|
|
19
|
+
function populate(selector,values,label){const el=$(selector),old=el.value;el.innerHTML=option('',label,old)+[...new Set(values.filter(Boolean))].sort().map(v=>option(v,pretty(v),old)).join('');}
|
|
20
|
+
function rebuild(){if(!find(selected))selected=graph().nodes[0]?.id||null;history.replaceState(null,'',selected?'#node='+encodeURIComponent(selected):location.pathname);graphView?.setData(graph());populate('#typeFilter',graph().nodes.map(n=>n.type),'All types');populate('#department',graph().nodes.map(n=>n.department),'All departments');populate('#statusFilter',graph().nodes.map(n=>n.status),'All statuses');$('#workspaceName').textContent=graph().name;$('#legend').innerHTML=[...new Set(graph().nodes.map(n=>n.type))].map(t=>'<span><i style="background:'+colorFor(t)+'"></i>'+h(pretty(t))+'</span>').join('');$('#undo').disabled=!store.state.history.length;renderMetrics();renderViews();renderPage();renderDetails();}
|
|
21
|
+
function renderMetrics(){const stats=insights(graph()),types=new Set(graph().nodes.map(n=>n.type));const metrics=[['Total entities',graph().nodes.length,types.size+' entity types','◈'],['Relationships',graph().edges.length,'Directed, typed connections','⇄'],['Needs attention',stats.atRisk.length+stats.overallocated.length,'At-risk + overallocated records','◷'],['Isolated entities',stats.isolated.length,'Records without connections','◎']];$('#metrics').innerHTML=metrics.map(([label,value,sub,icon])=>'<div class="metric"><div class="metricTop">'+label+'<span class="accent">'+icon+'</span></div><b>'+value+'</b><small>'+sub+'</small></div>').join('');}
|
|
22
|
+
const pageLabels={graph:['Graph explorer','Explore your connected world','Understand relationships across people, projects, applications, and beyond.'],directory:['Entity directory','Every record, in one place','Search, inspect, and maintain the entities that power your workspace.'],insights:['Insights & health','Turn connections into context','Spot capacity issues, project risks, and disconnected records.'],activity:['Activity history','A history of your changes','Local change history, including edits, imports, and undo actions.'],data:['Data management','Your workspace, your data','Import a graph, export a backup, or adapt this workspace to another domain.']};
|
|
23
|
+
function setPage(next){page=next;renderPage();window.scrollTo(0,0);}
|
|
24
|
+
function renderPage(){const labels=pageLabels[page];$('#pageCrumb').textContent=labels[0];$('#pageTitle').textContent=labels[1];$('#pageDescription').textContent=labels[2];$$('[data-page]').forEach(b=>b.classList.toggle('active',b.dataset.page===page));$('#explorer').hidden=!['graph','directory'].includes(page);for(const id of ['insights','activity','data'])$('#'+id).hidden=page!==id;$('#canvasWrap').hidden=page==='directory';$('#directory').hidden=page!=='directory';$('#showGraph').classList.toggle('active',page==='graph');$('#showDirectory').classList.toggle('active',page==='directory');if(page==='insights')renderInsights();if(page==='activity')renderActivity();if(page==='data')renderData();update();graphView?.resize();}
|
|
25
|
+
function filtered(){const q=$('#search').value.trim().toLowerCase(),type=$('#typeFilter').value,dep=$('#department').value,status=$('#statusFilter').value,near=focusDepth&&selected?neighbors(graph(),selected,focusDepth):null;return graph().nodes.filter(n=>(!q||[n.name,n.id,n.title,n.externalId,n.notes,n.email,...n.tags,...Object.values(n.attributes||{})].join(' ').toLowerCase().includes(q))&&(!type||n.type===type)&&(!dep||n.department===dep)&&(!status||n.status===status)&&(!bookmarksOnly||store.state.bookmarks.includes(n.id))&&(!near||near.has(n.id)));}
|
|
26
|
+
function update(){const list=filtered(),visible=new Set(list.map(n=>n.id));graphView?.update(visible,selected,path);$('#resultCount').textContent=list.length+' / '+graph().nodes.length+' entities';$('#empty').hidden=list.length>0;$('#bookmarkFilter').classList.toggle('activeFilter',bookmarksOnly);$('#bookmarkFilter').setAttribute('aria-pressed',String(bookmarksOnly));$('#pathBanner').hidden=!path.length;if(path.length)$('#pathBanner').textContent='Path · '+path.map(id=>find(id)?.name||id).join(' → ')+' (direction ignored)';renderTable(list);}
|
|
27
|
+
function renderTable(list){list=[...list].sort((a,b)=>String(a[sortKey]||'').localeCompare(String(b[sortKey]||''))*(sortAsc?1:-1));const stats=insights(graph());$('#directory').innerHTML='<table><thead><tr>'+['name','type','department','status'].map(k=>'<th><button data-sort="'+k+'">'+h(pretty(k))+(sortKey===k?(sortAsc?' ↑':' ↓'):'')+'</button></th>').join('')+'<th>Links</th></tr></thead><tbody>'+list.map(n=>'<tr><td><button data-node="'+h(n.id)+'">'+h(n.name)+'</button><small>'+h(n.externalId||n.id)+'</small></td><td>'+h(n.type)+'</td><td>'+h(n.department||'—')+'</td><td>'+h(n.status||'—')+'</td><td>'+stats.degree.get(n.id)+'</td></tr>').join('')+'</tbody></table>'+(list.length?'':'<div class="emptyState">No matching records.</div>')+'<div class="tableFoot">'+list.length+' records · Click an entity to open its details</div>';bindNodes($('#directory'));$('#directory').querySelectorAll('[data-sort]').forEach(b=>b.onclick=()=>{sortAsc=sortKey===b.dataset.sort?!sortAsc:true;sortKey=b.dataset.sort;update()});}
|
|
28
|
+
function bindNodes(root){root.querySelectorAll('[data-node]').forEach(b=>b.onclick=()=>{select(b.dataset.node);if(!['graph','directory'].includes(page))setPage('graph');});}
|
|
29
|
+
function select(id){if(!find(id))return;selected=id;history.replaceState(null,'','#node='+encodeURIComponent(id));renderDetails();update();$('#details').scrollTop=0;}
|
|
30
|
+
function nodeRow(id,sub=''){const n=find(id);if(!n)return '';return '<button class="connection" data-node="'+h(id)+'"><span class="mini" style="color:'+colorFor(n.type)+'">'+h(initial(n))+'</span><span><strong>'+h(n.name)+'</strong><small>'+h(sub||n.title||n.type)+'</small></span></button>';}
|
|
31
|
+
function section(title,body,count=''){return '<div class="sectionTitle">'+h(title)+'<span>'+h(count)+'</span></div>'+body;}
|
|
32
|
+
function renderDetails(){const n=find(selected);if(!n){$('#details').innerHTML='<div class="emptyState">Add an entity to start exploring.</div>';return;}const edges=graph().edges.filter(e=>e.source===n.id||e.target===n.id),stats=insights(graph()),load=stats.allocation.get(n.id);let html='<div class="detailTop">'+h(n.type.toUpperCase())+'<button id="bookmark" title="Toggle bookmark">'+(store.state.bookmarks.includes(n.id)?'★':'☆')+'</button></div><div class="bigAvatar" style="color:'+colorFor(n.type)+'">'+h(initial(n))+'</div><h2>'+h(n.name)+'</h2><div class="subtitle">'+h(n.title||n.externalId||n.id)+'</div>'+(n.status?'<span class="badge">'+h(n.status)+'</span>':'')+'<div class="detailActions"><button id="editEntity">Edit entity</button><button id="addRelationship">+ Connect</button><button id="copyLink" title="Copy entity link">↗</button></div>';
|
|
33
|
+
const meta={ID:n.externalId||n.id,Department:n.department,Location:n.location,'Due date':n.due,...n.attributes};html+='<div class="meta">'+Object.entries(meta).filter(([,v])=>v).map(([k,v])=>'<div><small>'+h(k)+'</small><span>'+h(v)+'</span></div>').join('')+'</div>';if(n.email)html+='<p><a href="mailto:'+h(n.email)+'">'+h(n.email)+'</a></p>';if(n.url)html+='<p><a href="'+h(n.url)+'" target="_blank" rel="noopener noreferrer">Open application ↗</a></p>';if(n.notes)html+='<p class="contentText">'+h(n.notes)+'</p>';if(n.progress!==undefined)html+=section('COMPLETION','<div class="load"><i style="width:'+n.progress+'%"></i></div><p>'+n.progress+'% complete</p>');if(load!==undefined)html+=section('TOTAL ALLOCATION','<div class="load"><i style="width:'+Math.min(load,100)+'%;background:'+(load>100?'#f4a088':'#78e4c4')+'"></i></div><p>'+load+'% allocated'+(load>100?' · Over capacity':'')+'</p>');if(n.tags.length)html+=section('TAGS & SKILLS','<div class="chips">'+n.tags.map(t=>'<span>'+h(t)+'</span>').join('')+'</div>');
|
|
34
|
+
html+=section('RELATIONSHIPS',edges.map(e=>'<div class="relationshipRow">'+nodeRow(e.source===n.id?e.target:e.source,(e.source===n.id?'→ ':'← ')+pretty(e.kind)+(e.label?' · '+e.label:'')+(e.allocation!==undefined?' · '+e.allocation+'%':''))+'<button data-edge="'+h(e.id)+'" title="Edit relationship">✎</button></div>').join('')||'<p>No connections yet.</p>',edges.length);
|
|
35
|
+
if(n.type==='person'){const projects=new Set(graph().edges.filter(e=>e.source===n.id&&e.kind==='works_on').map(e=>e.target));const collaborators=[...new Set(graph().edges.filter(e=>e.kind==='works_on'&&projects.has(e.target)&&e.source!==n.id).map(e=>e.source))];if(collaborators.length)html+=section('WORKS WITH',collaborators.map(id=>nodeRow(id)).join(''),collaborators.length);}
|
|
36
|
+
html+='<label class="field full">Neighborhood<select id="focusDepth">'+option('0','Entire network',String(focusDepth))+option('1','Direct connections',String(focusDepth))+option('2','Within two hops',String(focusDepth))+'</select></label><button id="deleteEntity" class="danger full">Delete entity</button>';$('#details').innerHTML=html;bindNodes($('#details'));$('#editEntity').onclick=()=>editEntity(n);$('#addRelationship').onclick=()=>editRelationship();$('#details').querySelectorAll('[data-edge]').forEach(b=>b.onclick=()=>editRelationship(graph().edges.find(e=>e.id===b.dataset.edge)));$('#bookmark').onclick=()=>run(()=>{const values=new Set(store.state.bookmarks);values.has(n.id)?values.delete(n.id):values.add(n.id);store.preference('bookmarks',[...values]);renderDetails();update()});$('#focusDepth').onchange=e=>{focusDepth=Number(e.target.value);update()};$('#copyLink').onclick=async()=>{try{await navigator.clipboard.writeText(location.origin+location.pathname+'#node='+encodeURIComponent(n.id));toast('Entity link copied · requires the same workspace data')}catch{toast('Copy the address from your browser')}};$('#deleteEntity').onclick=()=>showModal('Delete '+n.name+'?','<p>This removes the entity and '+edges.length+' attached relationships. You can restore the previous graph with Undo.</p>',()=>{commit(removeNode(graph(),n.id),'Deleted entity '+n.name);},'Delete entity');}
|
|
37
|
+
function field(name,label,value='',type='text',extra=''){return '<label class="field">'+h(label)+'<input name="'+name+'" type="'+type+'" value="'+h(value)+'" '+extra+'></label>';}
|
|
38
|
+
function area(name,label,value='',extra=''){return '<label class="field wide">'+h(label)+'<textarea name="'+name+'" '+extra+'>'+h(value)+'</textarea></label>';}
|
|
39
|
+
function showModal(title,body,save,label='Save'){modalRevision=store.state.revision;$('#dialogTitle').textContent=title;$('#dialogBody').innerHTML=body;$('#submitDialog').textContent=label;$('#formError').hidden=true;$('#dialogForm').onsubmit=async e=>{e.preventDefault();try{if(store.state.revision!==modalRevision)throw Error('Workspace changed in another tab. Close this form and try again.');await save(new FormData($('#dialogForm')));$('#dialog').close();}catch(error){$('#formError').textContent=error.message;$('#formError').hidden=false;}};$('#dialog').showModal();}
|
|
40
|
+
function editEntity(existing){const n=existing||{id:'n-'+crypto.randomUUID(),name:'',type:'person',tags:[],attributes:{}};showModal(existing?'Edit entity':'Create entity','<div class="formGrid">'+field('name','Name',n.name,'text','required maxlength="120"')+field('type','Entity type',n.type,'text','required maxlength="120" list="types"')+'<datalist id="types">'+[...new Set([...graph().nodes.map(x=>x.type),'asset','customer','service'])].map(t=>option(t,t)).join('')+'</datalist>'+field('title','Role / description',n.title)+field('externalId','External ID / app ID',n.externalId)+field('department','Department / group',n.department)+field('status','Status',n.status||'Active','text','list="statuses"')+'<datalist id="statuses">'+['Active','On track','At risk','Discovery','Completed','Production','Staging','Archived'].map(t=>option(t,t)).join('')+'</datalist>'+field('email','Email',n.email,'email')+field('location','Location',n.location)+field('url','Application / resource URL',n.url,'url')+field('due','Due date',n.due,'date')+field('progress','Progress (%)',n.progress,'number','min="0" max="100" step="any"')+field('tags','Tags / skills (comma-separated)',n.tags.join(', '))+area('notes','Notes',n.notes,'maxlength="2000"')+area('attributes','Custom fields (JSON object of text values)',JSON.stringify(n.attributes||{},null,2))+'</div>',f=>{const node={...n};for(const k of ['name','type','title','externalId','department','status','email','location','url','due','notes'])node[k]=String(f.get(k)||'').trim();node.tags=String(f.get('tags')).split(',').map(s=>s.trim()).filter(Boolean);if(f.get('progress')==='')delete node.progress;else node.progress=Number(f.get('progress'));node.attributes=JSON.parse(f.get('attributes')||'{}');const next=clone(graph());if(existing)next.nodes[next.nodes.findIndex(x=>x.id===n.id)]=node;else next.nodes.push(node);commit(next,(existing?'Updated ':'Created ')+node.name);select(node.id);});}
|
|
41
|
+
function entityOptions(chosen){return graph().nodes.map(n=>option(n.id,n.name+' · '+n.type,chosen)).join('');}
|
|
42
|
+
function editRelationship(existing){const e=existing||{id:'r-'+crypto.randomUUID(),source:selected||graph().nodes[0]?.id,target:graph().nodes.find(n=>n.id!==selected)?.id,kind:'related_to'};if(graph().nodes.length<2){toast('Add at least two entities to connect them.');return;}showModal(existing?'Edit relationship':'Connect entities','<div class="formGrid"><label class="field">From<select name="source">'+entityOptions(e.source)+'</select></label><label class="field">To<select name="target">'+entityOptions(e.target)+'</select></label>'+field('kind','Relationship type',e.kind,'text','required list="kinds"')+'<datalist id="kinds">'+['reports_to','works_on','uses','owns','depends_on','related_to'].map(k=>option(k,pretty(k))).join('')+'</datalist>'+field('label','Role / label',e.label)+field('allocation','Allocation % (works_on only)',e.allocation,'number','min="0" max="100" step="any"')+'</div><p>Relationships are directional. Reporting relationships cannot contain cycles.</p>'+(existing?'<button type="button" id="removeRelationship" class="danger full">Remove this relationship</button>':''),f=>{const edge={id:e.id,source:f.get('source'),target:f.get('target'),kind:String(f.get('kind')).trim(),label:String(f.get('label')).trim()};if(f.get('allocation')!=='')edge.allocation=Number(f.get('allocation'));const next=clone(graph());if(existing)next.edges[next.edges.findIndex(x=>x.id===e.id)]=edge;else next.edges.push(edge);commit(next,(existing?'Updated':'Created')+' relationship '+pretty(edge.kind));});if(existing)$('#removeRelationship').onclick=()=>{run(()=>{const next=clone(graph());next.edges=next.edges.filter(x=>x.id!==e.id);commit(next,'Removed relationship '+pretty(e.kind));$('#dialog').close();});};}
|
|
43
|
+
function renderInsights(){const s=insights(graph());const types=[...new Set(graph().nodes.map(n=>n.type))];const rows=nodes=>nodes.map(n=>nodeRow(n.id)).join('')||'<p>No records in this category.</p>';$('#insights').innerHTML='<div class="panelGrid"><div class="card"><h2>Workspace composition</h2>'+types.map(t=>{const count=graph().nodes.filter(n=>n.type===t).length;return '<div class="bar"><span>'+h(pretty(t))+'</span><i style="width:'+count/Math.max(1,graph().nodes.length)*65+'%;background:'+colorFor(t)+'"></i><b>'+count+'</b></div>'}).join('')+'</div><div class="card"><h2>Most connected entities</h2>'+s.hubs.map(n=>nodeRow(n.id,s.degree.get(n.id)+' relationships')).join('')+'</div><div class="card"><h2>Capacity watch</h2><p>Totals from outgoing works_on allocations. Values above 100% need review.</p>'+graph().nodes.filter(n=>s.allocation.has(n.id)).sort((a,b)=>s.allocation.get(b.id)-s.allocation.get(a.id)).map(n=>nodeRow(n.id,s.allocation.get(n.id)+'% allocated'+(s.allocation.get(n.id)>100?' · Over capacity':''))).join('')+'</div><div class="card"><h2>At-risk records</h2>'+rows(s.atRisk)+'<h2 style="margin-top:25px">Disconnected records</h2>'+rows(s.isolated)+'</div></div>';bindNodes($('#insights'));}
|
|
44
|
+
function renderActivity(){$('#activity').innerHTML='<div class="card"><h2>Workspace change history</h2><div class="note">Stored in this browser; keeps the latest 200 events. This is an editable local history, not a tamper-proof compliance audit log.</div><button id="exportAudit">Export history</button>'+(store.state.audit.map(e=>'<div class="auditRow"><small>'+h(new Date(e.at).toLocaleString())+'</small><div><b>'+h(e.action)+'</b><span>'+h(e.actor)+'</span></div></div>').join('')||'<div class="emptyState">No changes yet. Edits and imports will appear here.</div>')+'</div>';$('#exportAudit').onclick=()=>download('orbit-activity.json',JSON.stringify(store.state.audit,null,2));}
|
|
45
|
+
function renderData(){$('#data').innerHTML='<div class="panelGrid"><div class="card"><h2>Import a workspace</h2><p>Preview and validate V2 graph JSON or migrate an Orbit V1 export. Replace your graph or merge by stable IDs.</p><p>Limits: 500 entities · 2,500 relationships · 2 MB file.</p><button id="importData" class="primary">Choose JSON file</button></div><div class="card"><h2>Export & recovery</h2><p>Keep portable graph backups. JSON preserves all records and relationships; CSV exports the currently filtered entity list.</p><div class="inlineActions"><button id="exportGraph">Graph JSON</button><button id="exportCsv">Filtered CSV</button><button id="exportRecovery">Full local backup</button></div><p>Full backup includes local history and views for inspection. Import accepts the graph from this backup; history and view restoration are not supported.</p></div><div class="card"><h2>Workspace settings</h2><p>Name: <strong>'+h(graph().name)+'</strong></p><button id="renameWorkspace">Rename workspace</button><button id="newWorkspace" class="full">Start an empty workspace</button><button id="sampleWorkspace" class="full">Load sample workspace</button></div><div class="card"><h2>Deployment readiness</h2><p>Available now: editable graph, schema validation, local persistence, recovery snapshots, graph analysis, and import/export.</p><div class="note">Not configured: identity provider, server-enforced permissions, shared database, real-time collaboration, tenant isolation, backups on a server, or compliance audit storage. The current server only serves static files on localhost.</div><p>Use fictional or approved local data until production controls are implemented.</p><button id="template">Download generic starter</button></div></div>';$('#importData').onclick=()=>$('#fileInput').click();$('#exportGraph').onclick=exportGraph;$('#exportCsv').onclick=()=>download('orbit-entities.csv',csvExport(filtered()),'text/csv;charset=utf-8');$('#exportRecovery').onclick=()=>download('orbit-local-backup.json',store.loadError?localStorage.getItem(KEY)||'{}':JSON.stringify(store.state,null,2));$('#renameWorkspace').onclick=()=>showModal('Rename workspace',field('name','Workspace name',graph().name,'text','required maxlength="100"'),f=>commit({...graph(),name:String(f.get('name')).trim()},'Renamed workspace'));$('#newWorkspace').onclick=()=>showModal('Start an empty workspace','<p>The current graph is replaced. Export a backup first; Undo can restore the previous graph.</p>'+field('name','Workspace name','My workspace','text','required maxlength="100"'),f=>{clearFilters();commit({schemaVersion:2,name:f.get('name'),nodes:[],edges:[]},'Started empty workspace');});$('#sampleWorkspace').onclick=()=>showModal('Load sample workspace','<p>This replaces the current graph with fictional sample data. You can undo this change.</p>',()=>{clearFilters();commit(seed,'Loaded sample workspace');},'Load sample');$('#template').onclick=()=>download('orbit-starter.json',JSON.stringify({schemaVersion:2,name:'Service landscape',nodes:[{id:'service-1',name:'Customer API',type:'service',status:'Production',tags:['critical'],attributes:{Tier:'1'}},{id:'db-1',name:'Customer database',type:'database',status:'Production',tags:[]}],edges:[{id:'dependency-1',source:'service-1',target:'db-1',kind:'depends_on',label:'Primary storage'}]},null,2));}
|
|
46
|
+
function exportGraph(){download('orbit-graph.json',JSON.stringify(graph(),null,2));}
|
|
47
|
+
function clearFilters(){$('#search').value='';$('#typeFilter').value='';$('#department').value='';$('#statusFilter').value='';bookmarksOnly=false;focusDepth=0;path=[];}
|
|
48
|
+
function renderViews(){$('#savedViews').innerHTML=store.state.views.map((v,i)=>'<div class="savedView"><button data-view="'+i+'">◈ '+h(v.name)+'</button><button data-remove-view="'+i+'" title="Delete saved view">×</button></div>').join('')||'<p style="padding:0 8px;font-size:10px">Save filters for quick access.</p>';$$('[data-view]').forEach(b=>b.onclick=()=>{const v=store.state.views[Number(b.dataset.view)];clearFilters();$('#search').value=v.query||'';$('#typeFilter').value=v.type||'';$('#department').value=v.department||'';$('#statusFilter').value=v.status||'';bookmarksOnly=!!v.bookmarks;selected=find(v.selected)?v.selected:selected;focusDepth=v.depth||0;$('#layout').value=v.layout||'cluster';graphView?.setLayout($('#layout').value);setPage('graph');renderDetails();});$$('[data-remove-view]').forEach(b=>b.onclick=()=>run(()=>{store.preference('views',store.state.views.filter((_,i)=>i!==Number(b.dataset.removeView)));renderViews();}));}
|
|
49
|
+
$('#saveView').onclick=()=>showModal('Save current view',field('name','View name','','text','required maxlength="60"'),f=>{if(store.state.views.length>=20)throw Error('Maximum 20 saved views. Remove an old view first.');store.preference('views',[...store.state.views,{name:f.get('name'),query:$('#search').value,type:$('#typeFilter').value,department:$('#department').value,status:$('#statusFilter').value,bookmarks:bookmarksOnly,selected,depth:focusDepth,layout:$('#layout').value}]);renderViews();toast('View saved');});
|
|
50
|
+
$('#pathFinder').onclick=()=>{if(graph().nodes.length<2){toast('Add two entities first.');return;}showModal('Find a connection path','<p>Find the shortest relationship chain between two entities. Traversal ignores edge direction and current filters.</p><div class="formGrid"><label class="field">Start<select name="start">'+entityOptions(selected)+'</select></label><label class="field">Destination<select name="end">'+entityOptions(graph().nodes.find(n=>n.id!==selected)?.id)+'</select></label></div>',f=>{const route=shortestPath(graph(),f.get('start'),f.get('end'));if(!route.length)throw Error('No connection path exists between these entities.');clearFilters();path=route;selected=f.get('start');setPage('graph');renderDetails();toast((route.length-1)+' connections in shortest path');},'Find path');};
|
|
51
|
+
$('#fileInput').onchange=async e=>{const file=e.target.files[0];e.target.value='';if(!file)return;try{if(file.size>LIMITS.bytes)throw Error('File exceeds the 2 MB limit.');const parsed=JSON.parse(await file.text()),incoming=migrateV1(parsed.graph||parsed);const existing=new Set(graph().nodes.map(n=>n.id)),overlap=incoming.nodes.filter(n=>existing.has(n.id)).length;showModal('Import preview','<div class="note">Validation passed · '+incoming.nodes.length+' entities · '+incoming.edges.length+' relationships<br>'+overlap+' entity IDs overlap the current workspace.</div><p>Workspace: '+h(incoming.name)+'</p><label class="field">Import mode<select name="mode"><option value="merge">Merge (incoming values replace matching IDs)</option><option value="replace">Replace entire graph</option></select></label><p>Merge preserves current records not present in the incoming file. Duplicate relationships with different IDs are rejected. Undo retains the previous graph.</p>',f=>{const next=f.get('mode')==='replace'?incoming:mergeGraphs(graph(),incoming);clearFilters();commit(next,'Imported '+incoming.nodes.length+' entities ('+f.get('mode')+')');},'Import data');}catch(error){showModal('Import could not be validated','<p class="contentText">'+h(error.message)+'</p>',()=>{},'Close');}};
|
|
52
|
+
$('#closeDialog').onclick=$('#cancelDialog').onclick=()=>$('#dialog').close();
|
|
53
|
+
$$('[data-page]').forEach(b=>{b.title=pageLabels[b.dataset.page][0];b.onclick=()=>setPage(b.dataset.page);});
|
|
54
|
+
$('#showGraph').onclick=()=>setPage('graph');$('#showDirectory').onclick=()=>setPage('directory');
|
|
55
|
+
for(const id of ['search','typeFilter','department','statusFilter'])$('#'+id).addEventListener(id==='search'?'input':'change',()=>{path=[];update();});
|
|
56
|
+
$('#bookmarkFilter').onclick=()=>{bookmarksOnly=!bookmarksOnly;path=[];update();};$('#reset').onclick=()=>{clearFilters();graphView?.reset();renderDetails();update();};
|
|
57
|
+
$('#addEntity').onclick=()=>editEntity();$('#export').onclick=exportGraph;$('#undo').onclick=()=>run(()=>{if(store.undo()){path=[];rebuild();toast('Restored previous graph');}});
|
|
58
|
+
$('#layout').onchange=()=>graphView?.setLayout($('#layout').value);$('#rotate').onclick=()=>{if(graphView){graphView.controls.autoRotate=!graphView.controls.autoRotate;$('#rotate').classList.toggle('activeFilter',graphView.controls.autoRotate);}};$('#zoomIn').onclick=()=>graphView?.zoom(.85);$('#zoomOut').onclick=()=>graphView?.zoom(1.15);$('#labelsToggle').onclick=()=>{if(graphView){graphView.showLabels=!graphView.showLabels;$('#labelsToggle').classList.toggle('activeFilter',!graphView.showLabels);}};$('#snapshot').onclick=()=>{if(!graphView){toast('3D rendering is unavailable');return;}const a=document.createElement('a');a.href=graphView.snapshot();a.download='orbit-3d-graph.png';a.click();toast('Downloaded graph geometry (HTML labels are excluded)');};
|
|
59
|
+
document.addEventListener('keydown',e=>{if(e.key==='/'&&!$('#dialog').open&&!['INPUT','TEXTAREA','SELECT'].includes(document.activeElement.tagName)){e.preventDefault();setPage('graph');$('#search').focus();}});
|
|
60
|
+
window.addEventListener('hashchange',()=>{const id=new URLSearchParams(location.hash.slice(1)).get('node');if(find(id))select(id);});
|
|
61
|
+
window.addEventListener('storage',e=>{if(e.key===KEY){try{store.reload();path=[];rebuild();toast('Workspace updated from another tab')}catch{warn('Another tab wrote invalid workspace data. Reload or export a recovery backup.');}}});
|
|
62
|
+
rebuild();
|
|
63
|
+
|
package/dist/graph.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import {OrbitControls} from './vendor/OrbitControls.js';
|
|
3
|
+
const palette=['#66dfc5','#ac9cff','#f2bf76','#83baff','#f099c9','#bddb85'];
|
|
4
|
+
export function colorFor(type){if(type==='person')return palette[0];if(type==='project')return palette[1];if(type==='app')return palette[2];let hash=0;for(const c of type)hash=(hash*31+c.charCodeAt(0))>>>0;return palette[hash%palette.length];}
|
|
5
|
+
export class GraphView {
|
|
6
|
+
constructor(host,onSelect){this.host=host;this.onSelect=onSelect;this.items=new Map();this.lines=[];this.layout='cluster';this.scene=new THREE.Scene();this.camera=new THREE.PerspectiveCamera(45,1,.1,2000);this.renderer=new THREE.WebGLRenderer({antialias:true,alpha:true,preserveDrawingBuffer:true});this.renderer.setPixelRatio(Math.min(devicePixelRatio,2));host.append(this.renderer.domElement);this.labels=document.createElement('div');this.labels.className='graphLabels';host.append(this.labels);this.controls=new OrbitControls(this.camera,this.renderer.domElement);this.controls.enableDamping=true;this.controls.minDistance=60;this.controls.maxDistance=800;this.controls.autoRotateSpeed=.4;this.reset();this.scene.add(new THREE.AmbientLight(0xffffff,2));const light=new THREE.DirectionalLight(0xffffff,2.5);light.position.set(20,100,150);this.scene.add(light);const grid=new THREE.GridHelper(500,30,0x304d58,0x21303f);grid.position.y=-65;grid.material.transparent=true;grid.material.opacity=.3;this.scene.add(grid);
|
|
7
|
+
this.observer=new ResizeObserver(()=>this.resize());this.observer.observe(host);const ray=new THREE.Raycaster();let down;this.renderer.domElement.addEventListener('pointerdown',e=>down=[e.clientX,e.clientY]);this.renderer.domElement.addEventListener('pointerup',e=>{if(!down||Math.hypot(e.clientX-down[0],e.clientY-down[1])>5)return;const r=host.getBoundingClientRect();ray.setFromCamera(new THREE.Vector2((e.clientX-r.left)/r.width*2-1,-(e.clientY-r.top)/r.height*2+1),this.camera);const hit=ray.intersectObjects([...this.items.values()].map(x=>x.mesh).filter(m=>m.visible))[0];if(hit)this.onSelect(hit.object.userData.id);});this.frame=()=>{this.raf=requestAnimationFrame(this.frame);if(!this.host.offsetWidth)return;this.controls.update();this.renderer.render(this.scene,this.camera);const r=this.host.getBoundingClientRect();for(const [id,item] of this.items){const p=item.mesh.position.clone().project(this.camera);item.label.style.left=(p.x+1)*r.width/2+'px';item.label.style.top=(-p.y+1)*r.height/2+16+'px';item.label.hidden=!item.mesh.visible||p.z>1||p.z< -1||(!this.showLabels&&id!==this.selected);}};this.showLabels=true;this.frame();
|
|
8
|
+
}
|
|
9
|
+
resize(){const r=this.host.getBoundingClientRect();if(!r.width||!r.height)return;this.camera.aspect=r.width/r.height;this.camera.updateProjectionMatrix();this.renderer.setSize(r.width,r.height);}
|
|
10
|
+
reset(){this.camera.position.set(30,190,310);this.controls?.target.set(0,0,0);}
|
|
11
|
+
setData(graph){this.data=graph;for(const x of this.items.values()){this.scene.remove(x.mesh);x.mesh.geometry.dispose();x.mesh.material.dispose();x.label.remove();}for(const l of this.lines){this.scene.remove(l.mesh);l.mesh.geometry.dispose();l.mesh.material.dispose();}this.items.clear();this.lines=[];for(const n of graph.nodes){const geometry=n.type==='project'?new THREE.IcosahedronGeometry(4):n.type==='app'?new THREE.BoxGeometry(5,5,5):new THREE.SphereGeometry(3,12,10);const mesh=new THREE.Mesh(geometry,new THREE.MeshStandardMaterial({color:colorFor(n.type),emissive:colorFor(n.type),emissiveIntensity:.3,transparent:true}));mesh.userData.id=n.id;this.scene.add(mesh);const label=document.createElement('button');label.className='nodeLabel';label.textContent=n.name;label.onclick=()=>this.onSelect(n.id);this.labels.append(label);this.items.set(n.id,{mesh,label,node:n});}for(const e of graph.edges){const mesh=new THREE.Line(new THREE.BufferGeometry(),new THREE.LineBasicMaterial({color:0x4e7186,transparent:true,opacity:.3}));this.scene.add(mesh);this.lines.push({edge:e,mesh});}this.setLayout(this.layout);}
|
|
12
|
+
setLayout(layout){this.layout=layout;const types=[...new Set(this.data.nodes.map(n=>n.type))];this.data.nodes.forEach((n,i)=>{let p;const group=this.data.nodes.filter(x=>x.type===n.type),j=group.findIndex(x=>x.id===n.id),t=types.indexOf(n.type),angle=j/group.length*Math.PI*2;if(layout==='sphere'){const y=1-i/Math.max(1,this.data.nodes.length-1)*2,r=Math.sqrt(1-y*y),a=i*Math.PI*(3-Math.sqrt(5));p=new THREE.Vector3(Math.cos(a)*r*125,y*95,Math.sin(a)*r*125);}else{const a=t/types.length*Math.PI*2;p=new THREE.Vector3(Math.cos(a)*85+Math.cos(angle)*42,layout==='flat'?0:Math.sin(j*2.4)*38,Math.sin(a)*75+Math.sin(angle)*42);}this.items.get(n.id).mesh.position.copy(p);});for(const l of this.lines){l.mesh.geometry.dispose();l.mesh.geometry=new THREE.BufferGeometry().setFromPoints([this.items.get(l.edge.source).mesh.position,this.items.get(l.edge.target).mesh.position]);}this.controls.enableRotate=layout!=='flat';if(layout==='flat')this.camera.position.set(0,330,.1);else this.reset();}
|
|
13
|
+
update(visible,selected,path=[]){this.selected=selected;const related=new Set([selected]);for(const e of this.data.edges){if(e.source===selected)related.add(e.target);if(e.target===selected)related.add(e.source);}for(const [id,x] of this.items){x.mesh.visible=visible.has(id);const active=!selected||related.has(id)||path.includes(id);x.mesh.material.opacity=active?1:.45;x.mesh.scale.setScalar(id===selected?1.6:path.includes(id)?1.3:1);x.label.classList.toggle('selected',id===selected);x.label.classList.toggle('pathNode',path.includes(id));x.label.style.opacity=active?1:.65;}for(const l of this.lines){l.mesh.visible=visible.has(l.edge.source)&&visible.has(l.edge.target);const inPath=path.some((id,i)=>i>0&&((path[i-1]===l.edge.source&&id===l.edge.target)||(path[i-1]===l.edge.target&&id===l.edge.source)));const active=l.edge.source===selected||l.edge.target===selected;l.mesh.material.color.set(inPath?'#ffffff':active?'#70dfc7':'#466176');l.mesh.material.opacity=inPath?1:active?.85:.22;}}
|
|
14
|
+
zoom(factor){this.camera.position.sub(this.controls.target).multiplyScalar(factor).add(this.controls.target);}
|
|
15
|
+
snapshot(){this.renderer.render(this.scene,this.camera);return this.renderer.domElement.toDataURL('image/png');}
|
|
16
|
+
}
|
package/dist/index.html
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Orbit 3D Explorer · Workspace</title><link rel="stylesheet" href="style.css"><script type="importmap">{"imports":{"three":"./vendor/three.module.js"}}</script></head><body>
|
|
3
|
+
<aside class="sidebar"><a class="brand" href="./">◈ <span>orbit<span class="mint">.</span></span><small>2.0</small></a><div class="workspaceBadge"><span class="workspaceIcon">◈</span><div><strong id="workspaceName">Loading workspace</strong><small>Local workspace</small></div></div><div class="navLabel">WORKSPACE</div><nav><button data-page="graph" class="active">⌘ <span>Graph explorer</span></button><button data-page="directory">☷ <span>Entity directory</span></button><button data-page="insights">▥ <span>Insights & health</span></button><button data-page="activity">◷ <span>Activity history</span></button><button data-page="data">⇄ <span>Data management</span></button></nav><div class="navLabel">SAVED VIEWS <button id="saveView" title="Save current filters">+</button></div><div id="savedViews"></div><div class="sidebarFoot"><span class="live"></span><div>Saved on this device<small>No cloud sync or SSO configured</small></div></div></aside>
|
|
4
|
+
<div class="shell"><header><div class="crumb">Workspace <span>/</span> <b id="pageCrumb">Graph explorer</b></div><div class="headerActions"><span id="saveStatus">Browser storage</span><button id="undo">↶ Undo</button><button id="export">↓ Export</button><button class="primary" id="addEntity">+ Add entity</button></div></header><main><div id="warning" role="status" hidden></div><div class="heading"><div><div class="eyebrow">CONTEXT FOR EVERY CONNECTION</div><h1 id="pageTitle">Explore your connected world</h1><p id="pageDescription">Understand relationships across people, projects, applications, and beyond.</p></div><span class="versionBadge">V2 / WORKSPACE EDITION</span></div><div class="metrics" id="metrics"></div>
|
|
5
|
+
<div id="explorer"><div class="toolbar"><label class="search">⌕ <input id="search" placeholder="Search entities, IDs, tags, or skills…" aria-label="Search entities"><kbd>/</kbd></label><select id="typeFilter" aria-label="Entity type"><option value="">All types</option></select><select id="department" aria-label="Department"><option value="">All departments</option></select><select id="statusFilter" aria-label="Status"><option value="">All statuses</option></select><button id="bookmarkFilter" aria-pressed="false">☆ Bookmarks</button><button id="reset">Reset</button></div><div class="content"><section class="graphPanel"><div class="panelToolbar"><div class="segmented"><button id="showGraph" class="active">3D graph</button><button id="showDirectory">Table</button></div><span id="resultCount"></span><button id="pathFinder">⇢ Find path</button><select id="layout" aria-label="Graph layout"><option value="cluster">Type clusters</option><option value="sphere">Sphere</option><option value="flat">2D map</option></select></div><div id="canvasWrap"><div id="scene"></div><div id="empty" hidden>No entities match these filters.</div><div id="pathBanner" hidden></div><div id="legend"></div><div class="graphTools"><button id="labelsToggle" title="Toggle node labels">Aa</button><button id="rotate" title="Toggle auto rotation">⟳</button><button id="zoomIn" title="Zoom in">+</button><button id="zoomOut" title="Zoom out">−</button><button id="snapshot" title="Download graph PNG">▣</button></div><div class="hint">Drag to orbit · Scroll to zoom · Click an entity to explore</div></div><div id="directory" hidden></div></section><aside id="details" class="details"></aside></div></div>
|
|
6
|
+
<section id="insights" class="pagePanel" hidden></section><section id="activity" class="pagePanel" hidden></section><section id="data" class="pagePanel" hidden></section><footer><span>Orbit 3D Explorer <b>2.0.0</b></span><span>Local workspace · Export backups regularly</span></footer></main></div>
|
|
7
|
+
<dialog id="dialog"><form id="dialogForm"><div class="dialogHead"><h2 id="dialogTitle"></h2><button type="button" id="closeDialog" aria-label="Close dialog">×</button></div><div id="dialogBody"></div><pre id="formError" role="alert" hidden></pre><div class="dialogActions"><button type="button" id="cancelDialog">Cancel</button><button type="submit" class="primary" id="submitDialog">Save</button></div></form></dialog><input id="fileInput" type="file" accept="application/json,.json" hidden><div id="toast" role="status"></div><script type="module" src="app.js"></script></body></html>
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/** Portable graph model. No browser or Three.js dependency. */
|
|
2
|
+
export const LIMITS = { nodes: 500, edges: 2500, bytes: 2_000_000 };
|
|
3
|
+
export const clone = value => structuredClone(value);
|
|
4
|
+
export const escapeHTML = value => String(value ?? '').replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
|
5
|
+
const text = (v, max=2000) => typeof v === 'string' && v.length <= max;
|
|
6
|
+
const idOK = v => typeof v === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,99}$/.test(v);
|
|
7
|
+
export function validateGraph(input) {
|
|
8
|
+
const errors=[];
|
|
9
|
+
if (!input || input.schemaVersion !== 2 || !Array.isArray(input.nodes) || !Array.isArray(input.edges)) return ['Expected schemaVersion: 2, nodes: [], and edges: [].'];
|
|
10
|
+
if (!text(input.name,100) || !input.name.trim()) errors.push('Workspace name is required (100 characters maximum).');
|
|
11
|
+
if(input.nodes.length>LIMITS.nodes || input.edges.length>LIMITS.edges) errors.push(`Limit: ${LIMITS.nodes} entities and ${LIMITS.edges} relationships.`);
|
|
12
|
+
const ids = new Set(), edgeIds = new Set(), keys = new Set();
|
|
13
|
+
for(const [i,n] of input.nodes.entries()) {
|
|
14
|
+
if(!n || typeof n!=='object'){errors.push(`Entity ${i+1} must be an object.`);continue;}
|
|
15
|
+
if(!idOK(n.id) || ids.has(n.id)) errors.push(`Invalid or duplicate entity ID: ${String(n.id).slice(0,100)}`);
|
|
16
|
+
ids.add(n.id);
|
|
17
|
+
for(const field of ['name','type']) if(!text(n[field],120)||!n[field].trim()) errors.push(`${n.id}: ${field} is required (120 characters maximum).`);
|
|
18
|
+
for(const field of ['title','department','status','email','location','url','notes','externalId']) if(n[field]!==undefined&&!text(n[field])) errors.push(`${n.id}: invalid ${field}.`);
|
|
19
|
+
if(n.url) {try{const u=new URL(n.url);if(!['https:','http:'].includes(u.protocol)) throw Error();}catch{errors.push(`${n.id}: URL must use http or https.`);}}
|
|
20
|
+
if(n.email && !/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(n.email)) errors.push(`${n.id}: invalid email.`);
|
|
21
|
+
if(!Array.isArray(n.tags)||n.tags.length>30||n.tags.some(t=>!text(t,80))) errors.push(`${n.id}: tags must contain up to 30 short strings.`);
|
|
22
|
+
if(n.progress!==undefined && (!Number.isFinite(n.progress)||n.progress<0||n.progress>100)) errors.push(`${n.id}: progress must be 0–100.`);
|
|
23
|
+
if(n.due && (!text(n.due,10)||!/^\d{4}-\d{2}-\d{2}$/.test(n.due)||Number.isNaN(Date.parse(n.due))||new Date(n.due).toISOString().slice(0,10)!==n.due)) errors.push(`${n.id}: due date must be a real ISO date (YYYY-MM-DD).`);
|
|
24
|
+
if(n.attributes!==undefined && (!n.attributes || Array.isArray(n.attributes) || typeof n.attributes!=='object' || Object.keys(n.attributes).length>30 || Object.entries(n.attributes).some(([k,v])=>!text(k,80)||!text(v,1000)))) errors.push(`${n.id}: attributes must be an object of up to 30 string values.`);
|
|
25
|
+
}
|
|
26
|
+
for(const [i,e] of input.edges.entries()) {
|
|
27
|
+
if(!e||typeof e!=='object'){errors.push(`Relationship ${i+1} must be an object.`);continue;}
|
|
28
|
+
if(!idOK(e.id)||edgeIds.has(e.id)) errors.push(`Invalid or duplicate relationship ID: ${String(e.id).slice(0,100)}`);
|
|
29
|
+
edgeIds.add(e.id);
|
|
30
|
+
if(!ids.has(e.source)||!ids.has(e.target)) errors.push(`${e.id}: relationship endpoint does not exist.`);
|
|
31
|
+
if(e.source===e.target) errors.push(`${e.id}: self relationships are not allowed.`);
|
|
32
|
+
if(!text(e.kind,120)||!e.kind.trim()) errors.push(`${e.id}: relationship type is required.`);
|
|
33
|
+
if(e.label!==undefined&&!text(e.label,200)) errors.push(`${e.id}: invalid relationship label.`);
|
|
34
|
+
if(e.allocation!==undefined && (!Number.isFinite(e.allocation)||e.allocation<0||e.allocation>100)) errors.push(`${e.id}: allocation must be 0–100.`);
|
|
35
|
+
if(e.allocation!==undefined && e.kind!=='works_on') errors.push(`${e.id}: allocation is only supported on works_on relationships.`);
|
|
36
|
+
const key=JSON.stringify([e.source,e.target,e.kind]);if(keys.has(key)) errors.push(`${e.id}: duplicate relationship.`);keys.add(key);
|
|
37
|
+
}
|
|
38
|
+
// Reporting lines form a forest, not a cycle or multiple-manager graph.
|
|
39
|
+
const managers = new Map();
|
|
40
|
+
for(const e of input.edges.filter(e=>e?.kind==='reports_to')) {
|
|
41
|
+
if(managers.has(e.source)) errors.push(`${e.source}: only one reporting manager is allowed.`);
|
|
42
|
+
managers.set(e.source,e.target);
|
|
43
|
+
}
|
|
44
|
+
for(const start of managers.keys()){const visited=new Set();let current=start;while(managers.has(current)){if(visited.has(current)){errors.push('Reporting relationships contain a cycle.');break;}visited.add(current);current=managers.get(current);}}
|
|
45
|
+
return [...new Set(errors)].slice(0,30);
|
|
46
|
+
}
|
|
47
|
+
export function normalizeGraph(raw){
|
|
48
|
+
const data=clone(raw);const errors=validateGraph(data);if(errors.length)throw new Error(errors.join('\n'));
|
|
49
|
+
// Explicit fields keep runtime objects and arbitrary imported metadata out of persisted data.
|
|
50
|
+
return {schemaVersion:2,name:data.name,nodes:data.nodes.map(n=>Object.fromEntries(['id','name','type','title','department','status','email','location','url','notes','externalId','tags','progress','due','attributes'].filter(k=>n[k]!==undefined).map(k=>[k,n[k]]))),edges:data.edges.map(e=>Object.fromEntries(['id','source','target','kind','label','allocation'].filter(k=>e[k]!==undefined).map(k=>[k,e[k]])))};
|
|
51
|
+
}
|
|
52
|
+
export function migrateV1(data){
|
|
53
|
+
if(data.schemaVersion===2)return normalizeGraph(data);
|
|
54
|
+
if(!Array.isArray(data.people)||!Array.isArray(data.projects)||!Array.isArray(data.applications))throw Error('Not a V2 graph or a V1 directory export.');
|
|
55
|
+
const nodes=[...data.people,...data.projects,...data.applications].map(n=>({id:n.id,name:n.name,type:n.type,title:n.title||'',department:n.department||'',status:n.status||n.environment||'Active',email:n.email||'',location:n.location||'',externalId:n.employeeId||n.appId||'',tags:n.skills||[],notes:n.description||'',...(n.progress!==undefined?{progress:n.progress}:{}),...(n.due?{due:new Date(n.due).toISOString().slice(0,10)}:{}),attributes:n.type==='person'?{'Joined':String(n.joined||''),'Time zone':n.timezone||''}:{}}));
|
|
56
|
+
const edges=[];const add=(source,target,kind,extra={})=>edges.push({id:`r${edges.length+1}`,source,target,kind,...extra});
|
|
57
|
+
for(const p of data.people)if(p.manager)add(p.id,p.manager,'reports_to');
|
|
58
|
+
for(const p of data.projects){for(const [id,label,allocation] of p.members)add(id,p.id,'works_on',{label,allocation});for(const id of p.apps)add(p.id,id,'uses');if(p.owner)add(p.owner,p.id,'owns');}
|
|
59
|
+
for(const a of data.applications)if(a.owner)add(a.owner,a.id,'owns');
|
|
60
|
+
return normalizeGraph({schemaVersion:2,name:data.workspace||'Imported workspace',nodes,edges});
|
|
61
|
+
}
|
|
62
|
+
export function neighbors(graph,id,depth=1){const found=new Set([id]);let layer=[id];for(let i=0;i<depth;i++){const next=[];for(const e of graph.edges){if(layer.includes(e.source)&&!found.has(e.target)){found.add(e.target);next.push(e.target);}if(layer.includes(e.target)&&!found.has(e.source)){found.add(e.source);next.push(e.source);}}layer=next;}return found;}
|
|
63
|
+
export function shortestPath(graph,start,end){if(!graph.nodes.some(n=>n.id===start)||!graph.nodes.some(n=>n.id===end))return [];const queue=[[start]],seen=new Set([start]);for(let i=0;i<queue.length;i++){const path=queue[i],last=path.at(-1);if(last===end)return path;for(const e of graph.edges){const next=e.source===last?e.target:e.target===last?e.source:null;if(next&&!seen.has(next)){seen.add(next);queue.push([...path,next]);}}}return [];}
|
|
64
|
+
export function insights(graph){const degree=new Map(graph.nodes.map(n=>[n.id,0])),allocation=new Map();for(const e of graph.edges){degree.set(e.source,degree.get(e.source)+1);degree.set(e.target,degree.get(e.target)+1);if(e.kind==='works_on')allocation.set(e.source,(allocation.get(e.source)||0)+(e.allocation||0));}return {isolated:graph.nodes.filter(n=>!degree.get(n.id)),atRisk:graph.nodes.filter(n=>n.status==='At risk'),overallocated:graph.nodes.filter(n=>(allocation.get(n.id)||0)>100),allocation,degree,hubs:[...graph.nodes].sort((a,b)=>degree.get(b.id)-degree.get(a.id)).slice(0,5)};}
|
|
65
|
+
export function removeNode(graph,id){const next=clone(graph);next.nodes=next.nodes.filter(n=>n.id!==id);next.edges=next.edges.filter(e=>e.source!==id&&e.target!==id);return next;}
|
|
66
|
+
export function mergeGraphs(current,incoming){const nodes=new Map(current.nodes.map(n=>[n.id,n])),edges=new Map(current.edges.map(e=>[e.id,e]));for(const n of incoming.nodes)nodes.set(n.id,n);for(const e of incoming.edges)edges.set(e.id,e);return normalizeGraph({...current,nodes:[...nodes.values()],edges:[...edges.values()]});}
|
|
67
|
+
export function csvExport(nodes){const fields=['id','name','type','department','status','externalId','email','location'];const quote=v=>'"'+String(v??'').replace(/^[=+@\-\t\r]/,"'$&").replaceAll('"','""')+'"';return [fields,...nodes.map(n=>fields.map(k=>n[k]))].map(row=>row.map(quote).join(',')).join('\r\n');}
|