tink-harness 1.9.7 → 1.9.9
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/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +23 -0
- package/VERSIONING.md +1 -1
- package/package.json +1 -1
- package/templates/tink/tools/render-harness-health-report.mjs +621 -328
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,29 @@ All notable changes to Tink are tracked here.
|
|
|
6
6
|
|
|
7
7
|
No unreleased changes yet.
|
|
8
8
|
|
|
9
|
+
## [1.9.9] - 2026-06-11
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- Rebuilt the harness map as a real Three.js WebGL scene: GPU-rendered 70k-particle spiral galaxy, nebulae, and starfield (no more SVG lag), with slow 3D auto-orbit and OrbitControls (drag to rotate, scroll to zoom, double-click to reset).
|
|
14
|
+
- Nodes are now textured 3D planets that rotate on their own axis; every node carries a screen-projected label and distinct type colors (harness blue, rule violet, memory teal, stage amber), so the map stays readable as a harness map.
|
|
15
|
+
- Neural pulses now travel along 3D edges; selection focus-dims unrelated planets/edges/labels, and all SVG-era satellites, orbit rings, and SMIL pulses were removed.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Offline fallback message when the three.js CDN is unreachable; prefers-reduced-motion disables auto-rotation, self-rotation, and pulses.
|
|
20
|
+
|
|
21
|
+
## [1.9.8] - 2026-06-11
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- Galaxy-styled harness map background: a slowly rotating 4-arm spiral of 760 particles (pink-to-cyan, screen-blended), 7 colored nebula glows, a breathing core glow, and a deeper space gradient — inspired by a Three.js galaxy reference while keeping the SVG map fully interactive.
|
|
26
|
+
- Neural signal pulses: glowing dots travel along every edge from source to target (3.2-7.4s, staggered), so connections read like firing synapses.
|
|
27
|
+
|
|
28
|
+
### Changed
|
|
29
|
+
|
|
30
|
+
- Signal pulses follow the map state: they hide with core mode/filters, dim in focus mode, and stay bright only on edges related to the selected node. Clicking the galaxy background clears the selection. prefers-reduced-motion disables rotation and pulses.
|
|
31
|
+
|
|
9
32
|
## [1.9.7] - 2026-06-10
|
|
10
33
|
|
|
11
34
|
### Added
|
package/VERSIONING.md
CHANGED
package/package.json
CHANGED
|
@@ -48,7 +48,8 @@ const COPY = {
|
|
|
48
48
|
heroText: 'Every visible Tink run, rule, memory reference, and harness relationship mapped into one local dashboard. This report only prepares suggestions and never edits reusable state.',
|
|
49
49
|
generated: 'GENERATED',
|
|
50
50
|
harnessMap: 'HARNESS MAP',
|
|
51
|
-
mapHelp: 'Harnesses, rules, memory, and stages are mapped from visible Tink records.
|
|
51
|
+
mapHelp: 'Harnesses, rules, memory, and stages are mapped from visible Tink records in 3D. Drag to orbit, scroll to zoom, click a planet to inspect it.',
|
|
52
|
+
graph3dOffline: 'The 3D map needs an internet connection to load three.js. Reconnect and refresh this report.',
|
|
52
53
|
graphControls: 'Graph controls',
|
|
53
54
|
full: 'Full',
|
|
54
55
|
core: 'Core',
|
|
@@ -262,7 +263,8 @@ COPY.ko = {
|
|
|
262
263
|
heroText: '보이는 Tink run, rule, memory reference, harness 관계를 하나의 로컬 대시보드로 보여줍니다. 이 보고서는 제안만 준비하며 재사용 상태를 직접 수정하지 않습니다.',
|
|
263
264
|
generated: '생성 시각',
|
|
264
265
|
harnessMap: '하네스 지도',
|
|
265
|
-
mapHelp: '보이는 Tink 기록에서 하네스, rule, memory, stage 관계를 그립니다. 휠로 확대,
|
|
266
|
+
mapHelp: '보이는 Tink 기록에서 하네스, rule, memory, stage 관계를 3D로 그립니다. 드래그로 회전, 휠로 확대, 행성을 클릭하면 자세히 볼 수 있습니다.',
|
|
267
|
+
graph3dOffline: '3D 지도를 불러오려면 인터넷 연결이 필요합니다 (three.js CDN). 연결 후 보고서를 새로고침하세요.',
|
|
266
268
|
graphControls: '그래프 조작',
|
|
267
269
|
full: '전체',
|
|
268
270
|
core: '핵심',
|
|
@@ -618,32 +620,19 @@ function buildGraphLayout(summary) {
|
|
|
618
620
|
glow: score >= 50 || radius >= 20
|
|
619
621
|
};
|
|
620
622
|
});
|
|
621
|
-
const
|
|
622
|
-
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
]
|
|
635
|
-
.filter((ring) => ring.count > 0)
|
|
636
|
-
.map((ring, ringIndex) => ({
|
|
637
|
-
...ring,
|
|
638
|
-
duration: (28 + ((seed >> (ringIndex * 3)) % 36)).toFixed(0),
|
|
639
|
-
reverse: ringIndex % 2 === 1,
|
|
640
|
-
phase: ((seed >> (ringIndex * 5)) % 628) / 100
|
|
641
|
-
}));
|
|
642
|
-
if (rings.length) {
|
|
643
|
-
orbitSystems.push({ parentId: node.id, x: node.x, y: node.y, rings });
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
const byId = new Map(positioned.map((node) => [node.id, node]));
|
|
623
|
+
const spatial = positioned.map((node) => {
|
|
624
|
+
const hash = hashString(`y3:${node.id}`);
|
|
625
|
+
const ySpread = node.type === 'harness' ? 7 : 11;
|
|
626
|
+
return {
|
|
627
|
+
...node,
|
|
628
|
+
x3: Number(((node.x - 545) * 0.055).toFixed(2)),
|
|
629
|
+
y3: Number((((hash % 200) / 200 - 0.5) * 2 * ySpread).toFixed(2)),
|
|
630
|
+
z3: Number(((node.y - 340) * 0.055).toFixed(2)),
|
|
631
|
+
r3: Number((0.4 + node.radius * 0.085).toFixed(2)),
|
|
632
|
+
spin: Number((0.04 + ((hash >> 6) % 100) / 100 * 0.14).toFixed(3))
|
|
633
|
+
};
|
|
634
|
+
});
|
|
635
|
+
const byId = new Map(spatial.map((node) => [node.id, node]));
|
|
647
636
|
const drawnEdges = getRenderableEdges(edges)
|
|
648
637
|
.map((edge) => ({
|
|
649
638
|
...edge,
|
|
@@ -653,26 +642,32 @@ function buildGraphLayout(summary) {
|
|
|
653
642
|
.filter((edge) => edge.sourceNode && edge.targetNode)
|
|
654
643
|
.slice(0, 240);
|
|
655
644
|
|
|
656
|
-
return { nodes:
|
|
645
|
+
return { nodes: spatial, edges: drawnEdges };
|
|
657
646
|
}
|
|
658
647
|
|
|
659
648
|
function renderGraphCanvas(summary, copy) {
|
|
660
|
-
const { nodes, edges
|
|
661
|
-
const
|
|
662
|
-
.
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
649
|
+
const { nodes, edges } = buildGraphLayout(summary);
|
|
650
|
+
const graphPayload = JSON.stringify({
|
|
651
|
+
nodes: nodes.map((node) => ({
|
|
652
|
+
id: node.id,
|
|
653
|
+
label: node.label,
|
|
654
|
+
type: TYPE_COLORS[node.type] ? node.type : 'unknown',
|
|
655
|
+
weight: Number(node.weight || 0),
|
|
656
|
+
recommendation: node.recommendation || '',
|
|
657
|
+
core: node.type === 'harness' || node.type === 'rule' || Number(node.weight || 0) > 1,
|
|
658
|
+
x: node.x3,
|
|
659
|
+
y: node.y3,
|
|
660
|
+
z: node.z3,
|
|
661
|
+
r: node.r3,
|
|
662
|
+
spin: node.spin,
|
|
663
|
+
glow: Boolean(node.glow)
|
|
664
|
+
})),
|
|
665
|
+
edges: edges.map((edge) => ({
|
|
666
|
+
source: edge.source,
|
|
667
|
+
target: edge.target,
|
|
668
|
+
count: Number(edge.count || 1)
|
|
669
|
+
}))
|
|
670
|
+
}).replaceAll('<', '\\u003c');
|
|
676
671
|
const mapTitle = copy.knowledgeGraph || copy.harnessMap || 'Harness map';
|
|
677
672
|
const mapEyebrow = copy.harnessMap && copy.harnessMap !== mapTitle ? copy.harnessMap : '';
|
|
678
673
|
return `
|
|
@@ -695,107 +690,10 @@ function renderGraphCanvas(summary, copy) {
|
|
|
695
690
|
</div>
|
|
696
691
|
</div>
|
|
697
692
|
</div>
|
|
698
|
-
<
|
|
699
|
-
<
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
<stop offset="60%" style="stop-color: #0A0C12"/>
|
|
703
|
-
<stop offset="100%" style="stop-color: #06070B"/>
|
|
704
|
-
</radialGradient>
|
|
705
|
-
${Object.entries(TYPE_COLORS).map(([type, color]) => `
|
|
706
|
-
<radialGradient id="node-grad-${escapeAttr(type)}" cx="32%" cy="28%" r="78%">
|
|
707
|
-
<stop offset="0%" style="stop-color: #FFFFFF; stop-opacity: 0.42"/>
|
|
708
|
-
<stop offset="38%" style="stop-color: ${escapeAttr(color)}; stop-opacity: 0.98"/>
|
|
709
|
-
<stop offset="100%" style="stop-color: ${escapeAttr(color)}; stop-opacity: 0.78"/>
|
|
710
|
-
</radialGradient>
|
|
711
|
-
`).join('')}
|
|
712
|
-
</defs>
|
|
713
|
-
<rect class="graph-bg" width="1090" height="680" fill="url(#graph-bg-grad)"/>
|
|
714
|
-
<g class="starfield" aria-hidden="true">
|
|
715
|
-
${stars.map((star) => `
|
|
716
|
-
<circle cx="${star.x}" cy="${star.y}" r="${star.r}" fill="#FFFFFF" opacity="${star.opacity}"${star.twinkle ? ` class="star-twinkle" style="--twinkle-delay: ${star.delay}ms"` : ''}/>
|
|
717
|
-
`).join('')}
|
|
718
|
-
</g>
|
|
719
|
-
<g id="graph-viewport">
|
|
720
|
-
<g class="edges">
|
|
721
|
-
${edges.map((edge, index) => `
|
|
722
|
-
<line
|
|
723
|
-
class="graph-edge"
|
|
724
|
-
style="--edge-delay: ${Math.min(index * 5, 850)}ms"
|
|
725
|
-
data-source="${escapeAttr(edge.source)}"
|
|
726
|
-
data-target="${escapeAttr(edge.target)}"
|
|
727
|
-
x1="${edge.sourceNode.x.toFixed(1)}"
|
|
728
|
-
y1="${edge.sourceNode.y.toFixed(1)}"
|
|
729
|
-
x2="${edge.targetNode.x.toFixed(1)}"
|
|
730
|
-
y2="${edge.targetNode.y.toFixed(1)}"
|
|
731
|
-
stroke="${escapeAttr(edge.sourceNode.color)}"
|
|
732
|
-
stroke-opacity="0.12"
|
|
733
|
-
stroke-width="${clamp(Number(edge.count || 1), 1, 5)}"
|
|
734
|
-
/>
|
|
735
|
-
`).join('')}
|
|
736
|
-
</g>
|
|
737
|
-
<g class="orbits" aria-hidden="true">
|
|
738
|
-
${orbitSystems.map((system) => `
|
|
739
|
-
<g class="orbit-system" data-parent="${escapeAttr(system.parentId)}" transform="translate(${system.x.toFixed(1)} ${system.y.toFixed(1)})">
|
|
740
|
-
${system.rings.map((ring) => {
|
|
741
|
-
const dots = Array.from({ length: ring.count }, (_, dotIndex) => {
|
|
742
|
-
const angle = ring.phase + (dotIndex * Math.PI * 2) / ring.count;
|
|
743
|
-
return `<circle class="orbit-dot" cx="${(Math.cos(angle) * ring.distance).toFixed(1)}" cy="${(Math.sin(angle) * ring.distance).toFixed(1)}" r="${ring.dotRadius}" fill="url(#node-grad-${escapeAttr(ring.kind)})"/>`;
|
|
744
|
-
}).join('');
|
|
745
|
-
return `
|
|
746
|
-
<circle class="orbit-ring" cx="0" cy="0" r="${ring.distance.toFixed(1)}"/>
|
|
747
|
-
<g class="orbit-spin${ring.reverse ? ' is-reverse' : ''}" style="--orbit-dur: ${ring.duration}s">${dots}</g>
|
|
748
|
-
`;
|
|
749
|
-
}).join('')}
|
|
750
|
-
</g>
|
|
751
|
-
`).join('')}
|
|
752
|
-
</g>
|
|
753
|
-
<g class="nodes">
|
|
754
|
-
${nodes.map((node, index) => {
|
|
755
|
-
const seed = hashString(node.id);
|
|
756
|
-
const floatDuration = (5 + (seed % 50) / 10).toFixed(1);
|
|
757
|
-
const floatDelay = -(seed % 4000);
|
|
758
|
-
const floatX = ((seed % 7) - 3).toFixed(1);
|
|
759
|
-
const floatY = (((seed >> 3) % 7) - 3).toFixed(1);
|
|
760
|
-
return `
|
|
761
|
-
<g class="node-float" style="--float-dur: ${floatDuration}s; --float-delay: ${floatDelay}ms; --float-x: ${floatX}px; --float-y: ${floatY}px">
|
|
762
|
-
${node.type === 'harness' && node.radius >= 15 ? `
|
|
763
|
-
<ellipse class="planet-ring" cx="${node.x.toFixed(1)}" cy="${node.y.toFixed(1)}" rx="${(node.radius * 1.75).toFixed(1)}" ry="${(node.radius * 0.5).toFixed(1)}" transform="rotate(-16 ${node.x.toFixed(1)} ${node.y.toFixed(1)})"/>
|
|
764
|
-
` : ''}
|
|
765
|
-
<circle
|
|
766
|
-
class="graph-node ${node.type === 'harness' ? 'is-interactive' : ''}"
|
|
767
|
-
style="--enter-delay: ${Math.min(index * 9, 1100)}ms"
|
|
768
|
-
tabindex="${node.type === 'harness' ? '0' : '-1'}"
|
|
769
|
-
role="${node.type === 'harness' ? 'button' : 'presentation'}"
|
|
770
|
-
aria-label="${escapeAttr(`${copy.tooltipPrefix}: ${node.label}`)}"
|
|
771
|
-
data-node-id="${escapeAttr(node.id)}"
|
|
772
|
-
data-node-type="${escapeAttr(node.type)}"
|
|
773
|
-
data-node-label="${escapeAttr(node.label)}"
|
|
774
|
-
data-node-weight="${escapeAttr(node.weight || 0)}"
|
|
775
|
-
data-core="${node.type === 'harness' || node.type === 'rule' || Number(node.weight || 0) > 1 ? 'true' : 'false'}"
|
|
776
|
-
data-recommendation="${escapeAttr(node.recommendation || '')}"
|
|
777
|
-
cx="${node.x.toFixed(1)}"
|
|
778
|
-
cy="${node.y.toFixed(1)}"
|
|
779
|
-
r="${node.radius.toFixed(1)}"
|
|
780
|
-
fill="url(#node-grad-${escapeAttr(TYPE_COLORS[node.type] ? node.type : 'unknown')})"
|
|
781
|
-
fill-opacity="${node.type === 'harness' ? '1' : '0.85'}"
|
|
782
|
-
stroke="${escapeAttr('var(--text-secondary)')}"
|
|
783
|
-
stroke-opacity="${node.glow ? '0.9' : '0.18'}"
|
|
784
|
-
stroke-width="${node.glow ? '1.8' : '0.8'}"
|
|
785
|
-
>
|
|
786
|
-
<title>${escapeHtml(node.id)}</title>
|
|
787
|
-
</circle>
|
|
788
|
-
</g>
|
|
789
|
-
`;
|
|
790
|
-
}).join('')}
|
|
791
|
-
</g>
|
|
792
|
-
<g class="labels">
|
|
793
|
-
${strongest.map((node) => `
|
|
794
|
-
<text x="${(node.x + node.radius + 7).toFixed(1)}" y="${(node.y + 4).toFixed(1)}">${escapeHtml(node.label)}</text>
|
|
795
|
-
`).join('')}
|
|
796
|
-
</g>
|
|
797
|
-
</g>
|
|
798
|
-
</svg>
|
|
693
|
+
<div class="graph-3d" id="graph-3d" role="img" aria-label="Harness health graph">
|
|
694
|
+
<p class="graph-3d-fallback" id="graph-3d-fallback" hidden>${escapeHtml(copy.graph3dOffline || 'The 3D map needs an internet connection to load three.js.')}</p>
|
|
695
|
+
</div>
|
|
696
|
+
<script type="application/json" id="graph-data">${graphPayload}</script>
|
|
799
697
|
<div class="graph-tooltip" id="graph-tooltip" role="status" aria-live="polite"></div>
|
|
800
698
|
<div class="map-caption">
|
|
801
699
|
<span id="graph-status">${escapeHtml(copy.showingAll)}</span>
|
|
@@ -803,8 +701,8 @@ function renderGraphCanvas(summary, copy) {
|
|
|
803
701
|
<span>${escapeHtml(copy.linesRelations)}</span>
|
|
804
702
|
</div>
|
|
805
703
|
<div class="map-legend" aria-label="${escapeAttr(copy.nodeTypes || 'Node types')}">
|
|
806
|
-
${['harness', 'rule', '
|
|
807
|
-
<span class="legend-chip"><i style="background: ${escapeAttr(
|
|
704
|
+
${[['harness', 'var(--accent)'], ['rule', '#9B8CFF'], ['memory', '#4EC9B0'], ['stage', '#D7A65A']].map(([type, color]) => `
|
|
705
|
+
<span class="legend-chip"><i style="background: ${escapeAttr(color)}"></i>${escapeHtml(type)}</span>
|
|
808
706
|
`).join('')}
|
|
809
707
|
</div>
|
|
810
708
|
</section>
|
|
@@ -1350,11 +1248,7 @@ function renderScript(harnesses, copy) {
|
|
|
1350
1248
|
const byHarnessId = new Map(harnessData.map((item) => ['harness:' + item.id, item]));
|
|
1351
1249
|
const selectedPanel = document.getElementById('selected-panel');
|
|
1352
1250
|
const graphStatus = document.getElementById('graph-status');
|
|
1353
|
-
const tooltip = document.getElementById('graph-tooltip');
|
|
1354
1251
|
const filterStatus = document.getElementById('recommendation-filter-status');
|
|
1355
|
-
const nodes = Array.from(document.querySelectorAll('.graph-node'));
|
|
1356
|
-
const interactiveNodes = nodes.filter((item) => item.classList.contains('is-interactive'));
|
|
1357
|
-
const edges = Array.from(document.querySelectorAll('.graph-edge'));
|
|
1358
1252
|
const cards = Array.from(document.querySelectorAll('.harness-card'));
|
|
1359
1253
|
const recLabelByFilter = Object.fromEntries(
|
|
1360
1254
|
Array.from(document.querySelectorAll('[data-filter-rec]')).map((button) => [
|
|
@@ -1373,51 +1267,25 @@ function renderScript(harnesses, copy) {
|
|
|
1373
1267
|
const text = String(value ?? '').trim();
|
|
1374
1268
|
return text && text.toLowerCase() !== 'unknown' ? text : (copy.notSet || 'Not set');
|
|
1375
1269
|
};
|
|
1376
|
-
const nodeById = (id) => nodes.find((node) => node.dataset.nodeId === id);
|
|
1377
1270
|
const setStatus = (value) => {
|
|
1378
1271
|
if (graphStatus) graphStatus.textContent = value;
|
|
1379
1272
|
};
|
|
1380
1273
|
const setFilterStatus = (value) => {
|
|
1381
1274
|
if (filterStatus) filterStatus.textContent = value;
|
|
1382
1275
|
};
|
|
1383
|
-
const graphCanvas = document.querySelector('.graph-canvas');
|
|
1384
|
-
const orbitSystems = Array.from(document.querySelectorAll('.orbit-system'));
|
|
1385
1276
|
const defaultSelectedPanel = selectedPanel ? selectedPanel.innerHTML : '';
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
system.classList.toggle('is-dimmed', Boolean(hasSelection && !related));
|
|
1394
|
-
});
|
|
1395
|
-
}
|
|
1396
|
-
function clearSelection() {
|
|
1397
|
-
nodes.forEach((item) => item.classList.remove('is-selected', 'is-related'));
|
|
1398
|
-
edges.forEach((item) => item.classList.remove('is-related'));
|
|
1399
|
-
cards.forEach((item) => item.classList.remove('is-selected'));
|
|
1400
|
-
if (graphCanvas) graphCanvas.classList.remove('has-selection');
|
|
1401
|
-
syncOrbits();
|
|
1402
|
-
}
|
|
1403
|
-
function selectNode(node) {
|
|
1404
|
-
clearSelection();
|
|
1405
|
-
node.classList.add('is-selected');
|
|
1406
|
-
if (graphCanvas) graphCanvas.classList.add('has-selection');
|
|
1407
|
-
const id = node.dataset.nodeId;
|
|
1408
|
-
const item = byHarnessId.get(id);
|
|
1409
|
-
edges.forEach((edge) => {
|
|
1410
|
-
const related = edge.dataset.source === id || edge.dataset.target === id;
|
|
1411
|
-
edge.classList.toggle('is-related', related);
|
|
1412
|
-
if (related) {
|
|
1413
|
-
const otherId = edge.dataset.source === id ? edge.dataset.target : edge.dataset.source;
|
|
1414
|
-
const other = nodeById(otherId);
|
|
1415
|
-
if (other) other.classList.add('is-related');
|
|
1277
|
+
window.__tinkGraphState = { mode: 'full', filter: null, pendingSelect: null };
|
|
1278
|
+
window.__tinkGraphBridge = {
|
|
1279
|
+
onSelect(info) {
|
|
1280
|
+
if (!info) {
|
|
1281
|
+
cards.forEach((card) => card.classList.remove('is-selected'));
|
|
1282
|
+
if (selectedPanel && defaultSelectedPanel) selectedPanel.innerHTML = defaultSelectedPanel;
|
|
1283
|
+
return;
|
|
1416
1284
|
}
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
if (
|
|
1285
|
+
const item = byHarnessId.get(info.id);
|
|
1286
|
+
cards.forEach((card) => card.classList.toggle('is-selected', Boolean(item) && card.dataset.harnessId === item.id));
|
|
1287
|
+
if (!selectedPanel) return;
|
|
1288
|
+
if (item) {
|
|
1421
1289
|
selectedPanel.innerHTML = '<p class="eyebrow">' + esc(copy.selected) + '</p>' +
|
|
1422
1290
|
'<h2>' + esc(item.id) + '</h2>' +
|
|
1423
1291
|
'<p>' + esc((item.reason || copy.clickNode)) + '</p>' +
|
|
@@ -1427,32 +1295,29 @@ function renderScript(harnesses, copy) {
|
|
|
1427
1295
|
'<div><dt>' + esc(copy.uses) + '</dt><dd>' + esc(item.uses) + '</dd></div>' +
|
|
1428
1296
|
'<div><dt>' + esc(copy.score) + '</dt><dd>' + esc(item.score) + '</dd></div>' +
|
|
1429
1297
|
'</dl>';
|
|
1298
|
+
} else {
|
|
1299
|
+
selectedPanel.innerHTML = '<p class="eyebrow">' + esc(copy.selected) + '</p><h2>' + esc(info.label) + '</h2><p>' + esc(info.id) + '</p><dl><div><dt>' + esc(copy.type) + '</dt><dd>' + esc(displayValue(info.type)) + '</dd></div><div><dt>' + esc(copy.weight) + '</dt><dd>' + esc(info.weight) + '</dd></div></dl>';
|
|
1430
1300
|
}
|
|
1431
|
-
} else if (selectedPanel) {
|
|
1432
|
-
selectedPanel.innerHTML = '<p class="eyebrow">' + esc(copy.selected) + '</p><h2>' + esc(node.dataset.nodeLabel) + '</h2><p>' + esc(id) + '</p><dl><div><dt>' + esc(copy.type) + '</dt><dd>' + esc(displayValue(node.dataset.nodeType)) + '</dd></div><div><dt>' + esc(copy.weight) + '</dt><dd>' + esc(node.dataset.nodeWeight) + '</dd></div></dl>';
|
|
1433
1301
|
}
|
|
1434
|
-
|
|
1435
|
-
}
|
|
1302
|
+
};
|
|
1436
1303
|
function selectHarness(id) {
|
|
1437
|
-
const
|
|
1438
|
-
if (
|
|
1439
|
-
|
|
1440
|
-
node.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' });
|
|
1441
|
-
}
|
|
1304
|
+
const nodeId = 'harness:' + id;
|
|
1305
|
+
if (window.__tinkGraph) window.__tinkGraph.select(nodeId);
|
|
1306
|
+
else window.__tinkGraphState.pendingSelect = nodeId;
|
|
1442
1307
|
}
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
const
|
|
1446
|
-
|
|
1447
|
-
|
|
1308
|
+
document.querySelectorAll('[data-mode]').forEach((button) => {
|
|
1309
|
+
button.addEventListener('click', () => {
|
|
1310
|
+
const mode = button.dataset.mode;
|
|
1311
|
+
document.querySelectorAll('[data-mode]').forEach((item) => {
|
|
1312
|
+
const active = item.dataset.mode === mode;
|
|
1313
|
+
item.classList.toggle('active', active);
|
|
1314
|
+
item.setAttribute('aria-pressed', active ? 'true' : 'false');
|
|
1315
|
+
});
|
|
1316
|
+
window.__tinkGraphState.mode = mode;
|
|
1317
|
+
if (window.__tinkGraph) window.__tinkGraph.setMode(mode);
|
|
1318
|
+
setStatus(mode === 'core' ? copy.coreMode : copy.showingAll);
|
|
1448
1319
|
});
|
|
1449
|
-
|
|
1450
|
-
const visibleIds = new Set(nodes.filter((node) => !node.classList.contains('is-hidden')).map((node) => node.dataset.nodeId));
|
|
1451
|
-
edges.forEach((edge) => edge.classList.toggle('is-hidden', mode === 'core' && (!visibleIds.has(edge.dataset.source) || !visibleIds.has(edge.dataset.target))));
|
|
1452
|
-
document.querySelectorAll('.orbit-system').forEach((system) => system.classList.toggle('is-mode-hidden', mode === 'core'));
|
|
1453
|
-
setStatus(mode === 'core' ? copy.coreMode : copy.showingAll);
|
|
1454
|
-
syncOrbits();
|
|
1455
|
-
}
|
|
1320
|
+
});
|
|
1456
1321
|
function filterRecommendation(value, button) {
|
|
1457
1322
|
const alreadyActive = button.classList.contains('active-filter');
|
|
1458
1323
|
document.querySelectorAll('[data-filter-rec]').forEach((item) => {
|
|
@@ -1460,138 +1325,29 @@ function renderScript(harnesses, copy) {
|
|
|
1460
1325
|
item.setAttribute('aria-pressed', 'false');
|
|
1461
1326
|
});
|
|
1462
1327
|
if (alreadyActive) {
|
|
1463
|
-
nodes.forEach((node) => node.classList.remove('is-filtered-out'));
|
|
1464
|
-
edges.forEach((edge) => edge.classList.remove('is-filtered-out'));
|
|
1465
1328
|
cards.forEach((card) => card.classList.remove('is-filtered-out'));
|
|
1329
|
+
window.__tinkGraphState.filter = null;
|
|
1330
|
+
if (window.__tinkGraph) window.__tinkGraph.setFilter(null);
|
|
1466
1331
|
setStatus(copy.showingAll);
|
|
1467
1332
|
setFilterStatus(copy.showingAll);
|
|
1468
|
-
syncOrbits();
|
|
1469
1333
|
return;
|
|
1470
1334
|
}
|
|
1471
1335
|
button.classList.add('active-filter');
|
|
1472
1336
|
button.setAttribute('aria-pressed', 'true');
|
|
1473
|
-
nodes.forEach((node) => {
|
|
1474
|
-
const hide = node.dataset.nodeType === 'harness' && node.dataset.recommendation !== value;
|
|
1475
|
-
node.classList.toggle('is-filtered-out', hide);
|
|
1476
|
-
});
|
|
1477
|
-
const visibleIds = new Set(nodes.filter((node) => !node.classList.contains('is-filtered-out')).map((node) => node.dataset.nodeId));
|
|
1478
|
-
edges.forEach((edge) => edge.classList.toggle('is-filtered-out', !visibleIds.has(edge.dataset.source) || !visibleIds.has(edge.dataset.target)));
|
|
1479
1337
|
cards.forEach((card) => card.classList.toggle('is-filtered-out', card.dataset.recommendation !== value));
|
|
1338
|
+
window.__tinkGraphState.filter = value;
|
|
1339
|
+
if (window.__tinkGraph) window.__tinkGraph.setFilter(value);
|
|
1480
1340
|
const label = recLabelByFilter[value] || value;
|
|
1481
1341
|
setStatus(copy.filteredTo + ': ' + label);
|
|
1482
1342
|
setFilterStatus(copy.filteredTo + ': ' + label);
|
|
1483
|
-
syncOrbits();
|
|
1484
1343
|
}
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
if (
|
|
1489
|
-
|
|
1490
|
-
selectNode(node);
|
|
1491
|
-
}
|
|
1492
|
-
});
|
|
1493
|
-
node.addEventListener('pointerenter', () => {
|
|
1494
|
-
if (!tooltip) return;
|
|
1495
|
-
const box = node.getBoundingClientRect();
|
|
1496
|
-
const label = copy.tooltipPrefix + ': ' + node.dataset.nodeLabel;
|
|
1497
|
-
const typeLabel = node.dataset.nodeType ? ' - ' + node.dataset.nodeType : '';
|
|
1498
|
-
tooltip.textContent = label + typeLabel;
|
|
1499
|
-
tooltip.style.left = (Math.min(Math.ceil(box.right + 12), window.innerWidth - 220)) + 'px';
|
|
1500
|
-
tooltip.style.top = (Math.max(Math.ceil(box.top - 12), 12)) + 'px';
|
|
1501
|
-
tooltip.classList.add('is-visible');
|
|
1502
|
-
});
|
|
1503
|
-
node.addEventListener('pointerleave', () => {
|
|
1504
|
-
if (tooltip) {
|
|
1505
|
-
tooltip.classList.remove('is-visible');
|
|
1506
|
-
tooltip.textContent = '';
|
|
1507
|
-
}
|
|
1344
|
+
document.querySelectorAll('[data-zoom]').forEach((button) => {
|
|
1345
|
+
button.addEventListener('click', () => {
|
|
1346
|
+
if (!window.__tinkGraph) return;
|
|
1347
|
+
if (button.dataset.zoom === 'reset') window.__tinkGraph.reset();
|
|
1348
|
+
else window.__tinkGraph.zoom(button.dataset.zoom === 'in' ? 0.78 : 1.28);
|
|
1508
1349
|
});
|
|
1509
1350
|
});
|
|
1510
|
-
document.querySelectorAll('[data-mode]').forEach((button) => {
|
|
1511
|
-
button.addEventListener('click', () => applyMode(button.dataset.mode));
|
|
1512
|
-
});
|
|
1513
|
-
const graphSvg = document.querySelector('.graph-canvas');
|
|
1514
|
-
const graphViewport = document.getElementById('graph-viewport');
|
|
1515
|
-
if (graphSvg && graphViewport) {
|
|
1516
|
-
const view = { x: 0, y: 0, k: 1 };
|
|
1517
|
-
graphViewport.style.transformOrigin = '0 0';
|
|
1518
|
-
const applyView = () => {
|
|
1519
|
-
graphViewport.style.transform = 'translate(' + view.x + 'px, ' + view.y + 'px) scale(' + view.k + ')';
|
|
1520
|
-
};
|
|
1521
|
-
const svgPoint = (event) => {
|
|
1522
|
-
const pt = graphSvg.createSVGPoint();
|
|
1523
|
-
pt.x = event.clientX;
|
|
1524
|
-
pt.y = event.clientY;
|
|
1525
|
-
return pt.matrixTransform(graphSvg.getScreenCTM().inverse());
|
|
1526
|
-
};
|
|
1527
|
-
const zoomAt = (factor, cx, cy) => {
|
|
1528
|
-
const k = Math.min(5, Math.max(0.4, view.k * factor));
|
|
1529
|
-
const real = k / view.k;
|
|
1530
|
-
view.x = cx - (cx - view.x) * real;
|
|
1531
|
-
view.y = cy - (cy - view.y) * real;
|
|
1532
|
-
view.k = k;
|
|
1533
|
-
applyView();
|
|
1534
|
-
};
|
|
1535
|
-
const resetView = () => {
|
|
1536
|
-
graphViewport.classList.add('is-resetting');
|
|
1537
|
-
view.x = 0; view.y = 0; view.k = 1;
|
|
1538
|
-
applyView();
|
|
1539
|
-
setTimeout(() => graphViewport.classList.remove('is-resetting'), 360);
|
|
1540
|
-
};
|
|
1541
|
-
graphSvg.addEventListener('wheel', (event) => {
|
|
1542
|
-
event.preventDefault();
|
|
1543
|
-
const point = svgPoint(event);
|
|
1544
|
-
zoomAt(event.deltaY < 0 ? 1.15 : 1 / 1.15, point.x, point.y);
|
|
1545
|
-
}, { passive: false });
|
|
1546
|
-
let panState = null;
|
|
1547
|
-
graphSvg.addEventListener('pointerdown', (event) => {
|
|
1548
|
-
if (event.button !== 0) return;
|
|
1549
|
-
panState = { x: event.clientX, y: event.clientY, moved: false };
|
|
1550
|
-
graphSvg.setPointerCapture(event.pointerId);
|
|
1551
|
-
});
|
|
1552
|
-
graphSvg.addEventListener('pointermove', (event) => {
|
|
1553
|
-
if (!panState) return;
|
|
1554
|
-
const dx = event.clientX - panState.x;
|
|
1555
|
-
const dy = event.clientY - panState.y;
|
|
1556
|
-
if (!panState.moved && Math.abs(dx) + Math.abs(dy) < 3) return;
|
|
1557
|
-
panState.moved = true;
|
|
1558
|
-
graphSvg.classList.add('is-panning');
|
|
1559
|
-
const scale = 1090 / graphSvg.clientWidth;
|
|
1560
|
-
view.x += dx * scale;
|
|
1561
|
-
view.y += dy * scale;
|
|
1562
|
-
panState.x = event.clientX;
|
|
1563
|
-
panState.y = event.clientY;
|
|
1564
|
-
applyView();
|
|
1565
|
-
});
|
|
1566
|
-
let suppressClick = false;
|
|
1567
|
-
const endPan = () => {
|
|
1568
|
-
suppressClick = Boolean(panState && panState.moved);
|
|
1569
|
-
panState = null;
|
|
1570
|
-
graphSvg.classList.remove('is-panning');
|
|
1571
|
-
};
|
|
1572
|
-
graphSvg.addEventListener('pointerup', endPan);
|
|
1573
|
-
graphSvg.addEventListener('pointercancel', endPan);
|
|
1574
|
-
graphSvg.addEventListener('click', (event) => {
|
|
1575
|
-
if (suppressClick) {
|
|
1576
|
-
suppressClick = false;
|
|
1577
|
-
return;
|
|
1578
|
-
}
|
|
1579
|
-
if (event.target.classList.contains('graph-bg') || event.target.closest('.starfield')) {
|
|
1580
|
-
clearSelection();
|
|
1581
|
-
if (selectedPanel && defaultSelectedPanel) selectedPanel.innerHTML = defaultSelectedPanel;
|
|
1582
|
-
}
|
|
1583
|
-
});
|
|
1584
|
-
graphSvg.addEventListener('dblclick', (event) => {
|
|
1585
|
-
event.preventDefault();
|
|
1586
|
-
resetView();
|
|
1587
|
-
});
|
|
1588
|
-
document.querySelectorAll('[data-zoom]').forEach((button) => {
|
|
1589
|
-
button.addEventListener('click', () => {
|
|
1590
|
-
if (button.dataset.zoom === 'reset') return resetView();
|
|
1591
|
-
zoomAt(button.dataset.zoom === 'in' ? 1.3 : 1 / 1.3, 545, 340);
|
|
1592
|
-
});
|
|
1593
|
-
});
|
|
1594
|
-
}
|
|
1595
1351
|
const VALID_TABS = ['home', 'harnesses', 'memory', 'graph', 'activity'];
|
|
1596
1352
|
const navLinks = Array.from(document.querySelectorAll('.nav a[data-tab]'));
|
|
1597
1353
|
const pages = Array.from(document.querySelectorAll('.page'));
|
|
@@ -1643,6 +1399,448 @@ function renderScript(harnesses, copy) {
|
|
|
1643
1399
|
`;
|
|
1644
1400
|
}
|
|
1645
1401
|
|
|
1402
|
+
function renderGraph3DModule(copy) {
|
|
1403
|
+
const copyPayload = JSON.stringify({
|
|
1404
|
+
tooltipPrefix: copy.tooltipPrefix,
|
|
1405
|
+
type: copy.type
|
|
1406
|
+
}).replaceAll('<', '\\u003c');
|
|
1407
|
+
return `
|
|
1408
|
+
<script type="importmap">
|
|
1409
|
+
{
|
|
1410
|
+
"imports": {
|
|
1411
|
+
"three": "https://unpkg.com/three@0.162.0/build/three.module.js",
|
|
1412
|
+
"three/addons/": "https://unpkg.com/three@0.162.0/examples/jsm/"
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
</script>
|
|
1416
|
+
<script type="module">
|
|
1417
|
+
(async () => {
|
|
1418
|
+
const container = document.getElementById('graph-3d');
|
|
1419
|
+
const dataEl = document.getElementById('graph-data');
|
|
1420
|
+
if (!container || !dataEl) return;
|
|
1421
|
+
let THREE, OrbitControls;
|
|
1422
|
+
try {
|
|
1423
|
+
THREE = await import('three');
|
|
1424
|
+
({ OrbitControls } = await import('three/addons/controls/OrbitControls.js'));
|
|
1425
|
+
} catch (error) {
|
|
1426
|
+
const fallback = document.getElementById('graph-3d-fallback');
|
|
1427
|
+
if (fallback) fallback.hidden = false;
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
const data = JSON.parse(dataEl.textContent);
|
|
1431
|
+
const copy3d = ${copyPayload};
|
|
1432
|
+
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
1433
|
+
const rootStyle = getComputedStyle(document.documentElement);
|
|
1434
|
+
const cssColor = (name, fallbackColor) => (rootStyle.getPropertyValue(name) || '').trim() || fallbackColor;
|
|
1435
|
+
const TYPE_HEX = {
|
|
1436
|
+
harness: cssColor('--accent', '#5B8DEF'),
|
|
1437
|
+
rule: '#9B8CFF',
|
|
1438
|
+
memory: '#4EC9B0',
|
|
1439
|
+
stage: '#D7A65A',
|
|
1440
|
+
unknown: '#7A8194'
|
|
1441
|
+
};
|
|
1442
|
+
|
|
1443
|
+
const scene = new THREE.Scene();
|
|
1444
|
+
scene.fog = new THREE.FogExp2(0x000000, 0.0019);
|
|
1445
|
+
const camera = new THREE.PerspectiveCamera(60, 16 / 9, 0.1, 1000);
|
|
1446
|
+
const INITIAL_CAM = new THREE.Vector3(0, 24, 52);
|
|
1447
|
+
const INITIAL_TARGET = new THREE.Vector3(0, 0, 0);
|
|
1448
|
+
camera.position.copy(INITIAL_CAM);
|
|
1449
|
+
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
1450
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
1451
|
+
renderer.setClearColor(0x000005, 1);
|
|
1452
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
1453
|
+
container.appendChild(renderer.domElement);
|
|
1454
|
+
const labelLayer = document.createElement('div');
|
|
1455
|
+
labelLayer.className = 'graph3d-labels';
|
|
1456
|
+
container.appendChild(labelLayer);
|
|
1457
|
+
|
|
1458
|
+
const controls = new OrbitControls(camera, renderer.domElement);
|
|
1459
|
+
controls.enableDamping = true;
|
|
1460
|
+
controls.dampingFactor = 0.08;
|
|
1461
|
+
controls.autoRotate = !reducedMotion;
|
|
1462
|
+
controls.autoRotateSpeed = 0.12;
|
|
1463
|
+
controls.minDistance = 14;
|
|
1464
|
+
controls.maxDistance = 160;
|
|
1465
|
+
|
|
1466
|
+
scene.add(new THREE.AmbientLight(0x8e9ab8, 0.85));
|
|
1467
|
+
const keyLight = new THREE.DirectionalLight(0xffffff, 1.25);
|
|
1468
|
+
keyLight.position.set(30, 45, 25);
|
|
1469
|
+
scene.add(keyLight);
|
|
1470
|
+
const coreLight = new THREE.PointLight(0xffe8ff, 0.5, 260);
|
|
1471
|
+
coreLight.position.set(0, -14, 0);
|
|
1472
|
+
scene.add(coreLight);
|
|
1473
|
+
|
|
1474
|
+
function glowTexture(color) {
|
|
1475
|
+
const size = 128;
|
|
1476
|
+
const canvas = document.createElement('canvas');
|
|
1477
|
+
canvas.width = canvas.height = size;
|
|
1478
|
+
const context = canvas.getContext('2d');
|
|
1479
|
+
const gradient = context.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
|
|
1480
|
+
gradient.addColorStop(0, color);
|
|
1481
|
+
gradient.addColorStop(1, 'rgba(0,0,0,0)');
|
|
1482
|
+
context.fillStyle = gradient;
|
|
1483
|
+
context.fillRect(0, 0, size, size);
|
|
1484
|
+
return new THREE.CanvasTexture(canvas);
|
|
1485
|
+
}
|
|
1486
|
+
function glowSprite(color, scale) {
|
|
1487
|
+
const sprite = new THREE.Sprite(new THREE.SpriteMaterial({
|
|
1488
|
+
map: glowTexture(color),
|
|
1489
|
+
transparent: true,
|
|
1490
|
+
depthWrite: false,
|
|
1491
|
+
blending: THREE.AdditiveBlending
|
|
1492
|
+
}));
|
|
1493
|
+
sprite.scale.set(scale, scale, 1);
|
|
1494
|
+
return sprite;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
// --- galaxy backdrop (same recipe as the three.js reference) ---
|
|
1498
|
+
const GALAXY = { count: 70000, arms: 4, radius: 95, spin: 1.9, randomness: 0.28, power: 3, y: -16, flatten: 0.22 };
|
|
1499
|
+
const galaxyPositions = new Float32Array(GALAXY.count * 3);
|
|
1500
|
+
const galaxyColors = new Float32Array(GALAXY.count * 3);
|
|
1501
|
+
const insideColor = new THREE.Color(0xff66ff);
|
|
1502
|
+
const outsideColor = new THREE.Color(0x66ffff);
|
|
1503
|
+
for (let i = 0; i < GALAXY.count; i += 1) {
|
|
1504
|
+
const i3 = i * 3;
|
|
1505
|
+
const radius = Math.pow(Math.random(), GALAXY.power) * GALAXY.radius;
|
|
1506
|
+
const branchAngle = ((i % GALAXY.arms) / GALAXY.arms) * Math.PI * 2;
|
|
1507
|
+
const spinAngle = (radius / GALAXY.radius) * GALAXY.spin * Math.PI;
|
|
1508
|
+
const randomX = (Math.random() - 0.5) * GALAXY.randomness * radius;
|
|
1509
|
+
const randomY = (Math.random() - 0.5) * GALAXY.randomness * radius * GALAXY.flatten;
|
|
1510
|
+
const randomZ = (Math.random() - 0.5) * GALAXY.randomness * radius;
|
|
1511
|
+
galaxyPositions[i3] = Math.cos(branchAngle + spinAngle) * radius + randomX;
|
|
1512
|
+
galaxyPositions[i3 + 1] = GALAXY.y + randomY;
|
|
1513
|
+
galaxyPositions[i3 + 2] = Math.sin(branchAngle + spinAngle) * radius + randomZ;
|
|
1514
|
+
const mixed = insideColor.clone().lerp(outsideColor, radius / GALAXY.radius);
|
|
1515
|
+
mixed.multiplyScalar(0.55 + 0.35 * Math.random());
|
|
1516
|
+
galaxyColors[i3] = mixed.r;
|
|
1517
|
+
galaxyColors[i3 + 1] = mixed.g;
|
|
1518
|
+
galaxyColors[i3 + 2] = mixed.b;
|
|
1519
|
+
}
|
|
1520
|
+
const galaxyGeometry = new THREE.BufferGeometry();
|
|
1521
|
+
galaxyGeometry.setAttribute('position', new THREE.BufferAttribute(galaxyPositions, 3));
|
|
1522
|
+
galaxyGeometry.setAttribute('color', new THREE.BufferAttribute(galaxyColors, 3));
|
|
1523
|
+
const galaxy = new THREE.Points(galaxyGeometry, new THREE.PointsMaterial({
|
|
1524
|
+
size: 0.16,
|
|
1525
|
+
vertexColors: true,
|
|
1526
|
+
depthWrite: false,
|
|
1527
|
+
transparent: true,
|
|
1528
|
+
opacity: 0.8,
|
|
1529
|
+
blending: THREE.AdditiveBlending
|
|
1530
|
+
}));
|
|
1531
|
+
scene.add(galaxy);
|
|
1532
|
+
|
|
1533
|
+
const starGeometry = new THREE.BufferGeometry();
|
|
1534
|
+
const starCount = 4000;
|
|
1535
|
+
const starPositions = new Float32Array(starCount * 3);
|
|
1536
|
+
for (let i = 0; i < starCount; i += 1) {
|
|
1537
|
+
starPositions[i * 3] = (Math.random() - 0.5) * 520;
|
|
1538
|
+
starPositions[i * 3 + 1] = (Math.random() - 0.5) * 520;
|
|
1539
|
+
starPositions[i * 3 + 2] = (Math.random() - 0.5) * 520;
|
|
1540
|
+
}
|
|
1541
|
+
starGeometry.setAttribute('position', new THREE.BufferAttribute(starPositions, 3));
|
|
1542
|
+
const stars = new THREE.Points(starGeometry, new THREE.PointsMaterial({
|
|
1543
|
+
color: 0xffffff,
|
|
1544
|
+
size: 0.5,
|
|
1545
|
+
sizeAttenuation: true,
|
|
1546
|
+
transparent: true,
|
|
1547
|
+
opacity: 0.85
|
|
1548
|
+
}));
|
|
1549
|
+
scene.add(stars);
|
|
1550
|
+
|
|
1551
|
+
for (let i = 0; i < 10; i += 1) {
|
|
1552
|
+
const hue = Math.floor(Math.random() * 360);
|
|
1553
|
+
const nebula = glowSprite('hsla(' + hue + ', 80%, 55%, 0.4)', 50 + Math.random() * 70);
|
|
1554
|
+
nebula.position.set((Math.random() - 0.5) * 260, (Math.random() - 0.5) * 150 - 20, (Math.random() - 0.5) * 260);
|
|
1555
|
+
scene.add(nebula);
|
|
1556
|
+
}
|
|
1557
|
+
const coreGlow = glowSprite('rgba(255,240,255,0.85)', 26);
|
|
1558
|
+
coreGlow.position.set(0, GALAXY.y, 0);
|
|
1559
|
+
scene.add(coreGlow);
|
|
1560
|
+
|
|
1561
|
+
// --- harness map: spheres, edges, pulses, labels ---
|
|
1562
|
+
function surfaceTexture() {
|
|
1563
|
+
const canvas = document.createElement('canvas');
|
|
1564
|
+
canvas.width = 256;
|
|
1565
|
+
canvas.height = 128;
|
|
1566
|
+
const context = canvas.getContext('2d');
|
|
1567
|
+
context.fillStyle = '#B9BDC6';
|
|
1568
|
+
context.fillRect(0, 0, 256, 128);
|
|
1569
|
+
for (let i = 0; i < 70; i += 1) {
|
|
1570
|
+
const shade = 150 + Math.floor(Math.random() * 105);
|
|
1571
|
+
context.fillStyle = 'rgba(' + shade + ',' + shade + ',' + (shade + 8) + ',0.35)';
|
|
1572
|
+
context.beginPath();
|
|
1573
|
+
context.ellipse(Math.random() * 256, Math.random() * 128, 5 + Math.random() * 26, 3 + Math.random() * 10, Math.random() * Math.PI, 0, Math.PI * 2);
|
|
1574
|
+
context.fill();
|
|
1575
|
+
}
|
|
1576
|
+
for (let band = 0; band < 4; band += 1) {
|
|
1577
|
+
context.fillStyle = 'rgba(90,96,110,0.18)';
|
|
1578
|
+
context.fillRect(0, 16 + band * 30 + Math.random() * 8, 256, 5 + Math.random() * 8);
|
|
1579
|
+
}
|
|
1580
|
+
const texture = new THREE.CanvasTexture(canvas);
|
|
1581
|
+
texture.wrapS = THREE.RepeatWrapping;
|
|
1582
|
+
return texture;
|
|
1583
|
+
}
|
|
1584
|
+
const sphereTexture = surfaceTexture();
|
|
1585
|
+
const sphereGeometry = new THREE.SphereGeometry(1, 32, 24);
|
|
1586
|
+
const graphGroup = new THREE.Group();
|
|
1587
|
+
scene.add(graphGroup);
|
|
1588
|
+
|
|
1589
|
+
const nodeEntries = data.nodes.map((node) => {
|
|
1590
|
+
const colorHex = TYPE_HEX[node.type] || TYPE_HEX.unknown;
|
|
1591
|
+
const color = new THREE.Color(colorHex);
|
|
1592
|
+
const material = new THREE.MeshStandardMaterial({
|
|
1593
|
+
color,
|
|
1594
|
+
map: sphereTexture,
|
|
1595
|
+
roughness: 0.55,
|
|
1596
|
+
metalness: 0.1,
|
|
1597
|
+
emissive: color,
|
|
1598
|
+
emissiveIntensity: 0.25,
|
|
1599
|
+
transparent: true,
|
|
1600
|
+
opacity: 1
|
|
1601
|
+
});
|
|
1602
|
+
const mesh = new THREE.Mesh(sphereGeometry, material);
|
|
1603
|
+
mesh.position.set(node.x, node.y, node.z);
|
|
1604
|
+
mesh.scale.setScalar(node.r);
|
|
1605
|
+
mesh.userData = node;
|
|
1606
|
+
graphGroup.add(mesh);
|
|
1607
|
+
let ring = null;
|
|
1608
|
+
if (node.type === 'harness' && node.glow) {
|
|
1609
|
+
ring = new THREE.Mesh(
|
|
1610
|
+
new THREE.RingGeometry(node.r * 1.5, node.r * 2.15, 48),
|
|
1611
|
+
new THREE.MeshBasicMaterial({ color: 0x9aa6c0, side: THREE.DoubleSide, transparent: true, opacity: 0.3, depthWrite: false })
|
|
1612
|
+
);
|
|
1613
|
+
ring.position.copy(mesh.position);
|
|
1614
|
+
ring.rotation.x = Math.PI / 2.4;
|
|
1615
|
+
graphGroup.add(ring);
|
|
1616
|
+
}
|
|
1617
|
+
const label = document.createElement('span');
|
|
1618
|
+
label.className = 'graph3d-label' + (node.type === 'harness' ? '' : ' is-minor');
|
|
1619
|
+
label.textContent = node.label;
|
|
1620
|
+
labelLayer.appendChild(label);
|
|
1621
|
+
return { node, mesh, ring, label };
|
|
1622
|
+
});
|
|
1623
|
+
const entryById = new Map(nodeEntries.map((entry) => [entry.node.id, entry]));
|
|
1624
|
+
|
|
1625
|
+
const edgeEntries = data.edges.map((edge) => {
|
|
1626
|
+
const source = entryById.get(edge.source);
|
|
1627
|
+
const target = entryById.get(edge.target);
|
|
1628
|
+
if (!source || !target) return null;
|
|
1629
|
+
const geometry = new THREE.BufferGeometry().setFromPoints([source.mesh.position, target.mesh.position]);
|
|
1630
|
+
const material = new THREE.LineBasicMaterial({
|
|
1631
|
+
color: new THREE.Color(TYPE_HEX[source.node.type] || TYPE_HEX.unknown),
|
|
1632
|
+
transparent: true,
|
|
1633
|
+
opacity: 0.3,
|
|
1634
|
+
depthWrite: false,
|
|
1635
|
+
blending: THREE.AdditiveBlending
|
|
1636
|
+
});
|
|
1637
|
+
const line = new THREE.Line(geometry, material);
|
|
1638
|
+
graphGroup.add(line);
|
|
1639
|
+
const pulse = glowSprite('rgba(190,215,255,0.95)', 1.5);
|
|
1640
|
+
pulse.material.opacity = 0;
|
|
1641
|
+
graphGroup.add(pulse);
|
|
1642
|
+
return {
|
|
1643
|
+
edge,
|
|
1644
|
+
source,
|
|
1645
|
+
target,
|
|
1646
|
+
line,
|
|
1647
|
+
pulse,
|
|
1648
|
+
dur: 2.6 + Math.random() * 3.2,
|
|
1649
|
+
phase: Math.random()
|
|
1650
|
+
};
|
|
1651
|
+
}).filter(Boolean);
|
|
1652
|
+
|
|
1653
|
+
// --- state: selection, mode, filter ---
|
|
1654
|
+
const state = window.__tinkGraphState || { mode: 'full', filter: null, pendingSelect: null };
|
|
1655
|
+
let selectedId = null;
|
|
1656
|
+
function relatedIds(id) {
|
|
1657
|
+
const related = new Set([id]);
|
|
1658
|
+
edgeEntries.forEach((entry) => {
|
|
1659
|
+
if (entry.edge.source === id) related.add(entry.edge.target);
|
|
1660
|
+
if (entry.edge.target === id) related.add(entry.edge.source);
|
|
1661
|
+
});
|
|
1662
|
+
return related;
|
|
1663
|
+
}
|
|
1664
|
+
function applyState() {
|
|
1665
|
+
const related = selectedId ? relatedIds(selectedId) : null;
|
|
1666
|
+
nodeEntries.forEach((entry) => {
|
|
1667
|
+
const visible = state.mode !== 'core' || entry.node.core;
|
|
1668
|
+
entry.mesh.visible = visible;
|
|
1669
|
+
let dim = 1;
|
|
1670
|
+
if (state.filter && entry.node.type === 'harness' && entry.node.recommendation !== state.filter) dim = 0.12;
|
|
1671
|
+
if (related && !related.has(entry.node.id)) dim = Math.min(dim, 0.1);
|
|
1672
|
+
const isSelected = entry.node.id === selectedId;
|
|
1673
|
+
entry.mesh.material.opacity = dim;
|
|
1674
|
+
entry.mesh.material.emissiveIntensity = isSelected ? 0.85 : (related && related.has(entry.node.id) ? 0.45 : 0.25);
|
|
1675
|
+
entry.mesh.scale.setScalar(entry.node.r * (isSelected ? 1.3 : 1));
|
|
1676
|
+
if (entry.ring) {
|
|
1677
|
+
entry.ring.visible = visible;
|
|
1678
|
+
entry.ring.material.opacity = 0.3 * dim;
|
|
1679
|
+
}
|
|
1680
|
+
entry.label.style.opacity = visible ? String(Math.max(dim, isSelected ? 1 : 0)) : '0';
|
|
1681
|
+
entry.label.classList.toggle('is-selected', isSelected);
|
|
1682
|
+
});
|
|
1683
|
+
edgeEntries.forEach((entry) => {
|
|
1684
|
+
const visible = entry.source.mesh.visible && entry.target.mesh.visible;
|
|
1685
|
+
entry.line.visible = visible;
|
|
1686
|
+
entry.pulse.visible = visible && !reducedMotion;
|
|
1687
|
+
const isRelated = related && (entry.edge.source === selectedId || entry.edge.target === selectedId);
|
|
1688
|
+
const filtered = state.filter && (entry.source.mesh.material.opacity < 0.2 || entry.target.mesh.material.opacity < 0.2);
|
|
1689
|
+
entry.line.material.opacity = related ? (isRelated ? 0.85 : 0.04) : (filtered ? 0.05 : 0.3);
|
|
1690
|
+
entry.pulseFactor = related ? (isRelated ? 1 : 0.05) : (filtered ? 0.08 : 1);
|
|
1691
|
+
});
|
|
1692
|
+
}
|
|
1693
|
+
function emitSelection() {
|
|
1694
|
+
if (!window.__tinkGraphBridge) return;
|
|
1695
|
+
if (!selectedId) {
|
|
1696
|
+
window.__tinkGraphBridge.onSelect(null);
|
|
1697
|
+
return;
|
|
1698
|
+
}
|
|
1699
|
+
const entry = entryById.get(selectedId);
|
|
1700
|
+
window.__tinkGraphBridge.onSelect(entry ? {
|
|
1701
|
+
id: entry.node.id,
|
|
1702
|
+
label: entry.node.label,
|
|
1703
|
+
type: entry.node.type,
|
|
1704
|
+
weight: entry.node.weight
|
|
1705
|
+
} : null);
|
|
1706
|
+
}
|
|
1707
|
+
window.__tinkGraph = {
|
|
1708
|
+
select(id) {
|
|
1709
|
+
if (!entryById.has(id)) return;
|
|
1710
|
+
selectedId = id;
|
|
1711
|
+
applyState();
|
|
1712
|
+
emitSelection();
|
|
1713
|
+
},
|
|
1714
|
+
clear() {
|
|
1715
|
+
selectedId = null;
|
|
1716
|
+
applyState();
|
|
1717
|
+
emitSelection();
|
|
1718
|
+
},
|
|
1719
|
+
setMode(mode) {
|
|
1720
|
+
state.mode = mode;
|
|
1721
|
+
applyState();
|
|
1722
|
+
},
|
|
1723
|
+
setFilter(value) {
|
|
1724
|
+
state.filter = value;
|
|
1725
|
+
applyState();
|
|
1726
|
+
},
|
|
1727
|
+
zoom(factor) {
|
|
1728
|
+
camera.position.sub(controls.target).multiplyScalar(factor).add(controls.target);
|
|
1729
|
+
controls.update();
|
|
1730
|
+
},
|
|
1731
|
+
reset() {
|
|
1732
|
+
camera.position.copy(INITIAL_CAM);
|
|
1733
|
+
controls.target.copy(INITIAL_TARGET);
|
|
1734
|
+
controls.update();
|
|
1735
|
+
}
|
|
1736
|
+
};
|
|
1737
|
+
applyState();
|
|
1738
|
+
if (state.pendingSelect) {
|
|
1739
|
+
window.__tinkGraph.select(state.pendingSelect);
|
|
1740
|
+
state.pendingSelect = null;
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// --- picking: hover tooltip + click selection ---
|
|
1744
|
+
const raycaster = new THREE.Raycaster();
|
|
1745
|
+
const pointer = new THREE.Vector2();
|
|
1746
|
+
const tooltip = document.getElementById('graph-tooltip');
|
|
1747
|
+
let downAt = null;
|
|
1748
|
+
function pick(event) {
|
|
1749
|
+
const rect = renderer.domElement.getBoundingClientRect();
|
|
1750
|
+
pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
|
1751
|
+
pointer.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
|
1752
|
+
raycaster.setFromCamera(pointer, camera);
|
|
1753
|
+
const meshes = nodeEntries.filter((entry) => entry.mesh.visible && entry.mesh.material.opacity > 0.2).map((entry) => entry.mesh);
|
|
1754
|
+
const hits = raycaster.intersectObjects(meshes, false);
|
|
1755
|
+
return hits.length ? hits[0].object.userData : null;
|
|
1756
|
+
}
|
|
1757
|
+
renderer.domElement.addEventListener('pointerdown', (event) => {
|
|
1758
|
+
downAt = { x: event.clientX, y: event.clientY };
|
|
1759
|
+
});
|
|
1760
|
+
renderer.domElement.addEventListener('pointerup', (event) => {
|
|
1761
|
+
if (!downAt) return;
|
|
1762
|
+
const moved = Math.abs(event.clientX - downAt.x) + Math.abs(event.clientY - downAt.y);
|
|
1763
|
+
downAt = null;
|
|
1764
|
+
if (moved > 6) return;
|
|
1765
|
+
const hit = pick(event);
|
|
1766
|
+
if (hit) window.__tinkGraph.select(hit.id);
|
|
1767
|
+
else window.__tinkGraph.clear();
|
|
1768
|
+
});
|
|
1769
|
+
renderer.domElement.addEventListener('pointermove', (event) => {
|
|
1770
|
+
const hit = pick(event);
|
|
1771
|
+
renderer.domElement.style.cursor = hit ? 'pointer' : 'grab';
|
|
1772
|
+
if (!tooltip) return;
|
|
1773
|
+
if (hit) {
|
|
1774
|
+
tooltip.textContent = copy3d.tooltipPrefix + ': ' + hit.label + ' - ' + hit.type;
|
|
1775
|
+
tooltip.style.left = Math.min(event.clientX + 14, window.innerWidth - 240) + 'px';
|
|
1776
|
+
tooltip.style.top = Math.max(event.clientY - 12, 12) + 'px';
|
|
1777
|
+
tooltip.classList.add('is-visible');
|
|
1778
|
+
} else {
|
|
1779
|
+
tooltip.classList.remove('is-visible');
|
|
1780
|
+
}
|
|
1781
|
+
});
|
|
1782
|
+
renderer.domElement.addEventListener('pointerleave', () => {
|
|
1783
|
+
if (tooltip) tooltip.classList.remove('is-visible');
|
|
1784
|
+
});
|
|
1785
|
+
renderer.domElement.addEventListener('dblclick', () => window.__tinkGraph.reset());
|
|
1786
|
+
|
|
1787
|
+
// --- sizing ---
|
|
1788
|
+
function resize() {
|
|
1789
|
+
const width = container.clientWidth;
|
|
1790
|
+
const height = container.clientHeight;
|
|
1791
|
+
if (!width || !height) return;
|
|
1792
|
+
camera.aspect = width / height;
|
|
1793
|
+
camera.updateProjectionMatrix();
|
|
1794
|
+
renderer.setSize(width, height);
|
|
1795
|
+
}
|
|
1796
|
+
new ResizeObserver(resize).observe(container);
|
|
1797
|
+
resize();
|
|
1798
|
+
|
|
1799
|
+
// --- animate ---
|
|
1800
|
+
const clock = new THREE.Clock();
|
|
1801
|
+
const projection = new THREE.Vector3();
|
|
1802
|
+
function animate() {
|
|
1803
|
+
requestAnimationFrame(animate);
|
|
1804
|
+
const delta = clock.getDelta();
|
|
1805
|
+
const elapsed = clock.elapsedTime;
|
|
1806
|
+
if (!container.clientWidth) return;
|
|
1807
|
+
controls.update();
|
|
1808
|
+
if (!reducedMotion) {
|
|
1809
|
+
galaxy.rotation.y += delta * 0.012;
|
|
1810
|
+
stars.rotation.y += delta * 0.004;
|
|
1811
|
+
nodeEntries.forEach((entry) => {
|
|
1812
|
+
entry.mesh.rotation.y += delta * entry.node.spin;
|
|
1813
|
+
});
|
|
1814
|
+
edgeEntries.forEach((entry) => {
|
|
1815
|
+
const t = ((elapsed / entry.dur) + entry.phase) % 1;
|
|
1816
|
+
entry.pulse.position.lerpVectors(entry.source.mesh.position, entry.target.mesh.position, t);
|
|
1817
|
+
entry.pulse.material.opacity = Math.sin(Math.PI * t) * 0.9 * (entry.pulseFactor ?? 1);
|
|
1818
|
+
});
|
|
1819
|
+
}
|
|
1820
|
+
const width = container.clientWidth;
|
|
1821
|
+
const height = container.clientHeight;
|
|
1822
|
+
nodeEntries.forEach((entry) => {
|
|
1823
|
+
projection.copy(entry.mesh.position);
|
|
1824
|
+
projection.y += entry.mesh.scale.x + 0.6;
|
|
1825
|
+
projection.project(camera);
|
|
1826
|
+
const behind = projection.z > 1;
|
|
1827
|
+
if (behind || !entry.mesh.visible) {
|
|
1828
|
+
entry.label.style.display = 'none';
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
entry.label.style.display = '';
|
|
1832
|
+
const x = (projection.x * 0.5 + 0.5) * width;
|
|
1833
|
+
const y = (-projection.y * 0.5 + 0.5) * height;
|
|
1834
|
+
entry.label.style.transform = 'translate(-50%, -100%) translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px)';
|
|
1835
|
+
});
|
|
1836
|
+
renderer.render(scene, camera);
|
|
1837
|
+
}
|
|
1838
|
+
animate();
|
|
1839
|
+
})();
|
|
1840
|
+
</script>
|
|
1841
|
+
`;
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1646
1844
|
function renderStyles() {
|
|
1647
1845
|
return `<style>
|
|
1648
1846
|
:root {
|
|
@@ -2265,6 +2463,39 @@ function renderStyles() {
|
|
|
2265
2463
|
to { opacity: 0.6; }
|
|
2266
2464
|
}
|
|
2267
2465
|
|
|
2466
|
+
.galaxy-layer { pointer-events: none; }
|
|
2467
|
+
|
|
2468
|
+
.nebula { mix-blend-mode: screen; }
|
|
2469
|
+
|
|
2470
|
+
.galaxy-spiral {
|
|
2471
|
+
transform-origin: 545px 340px;
|
|
2472
|
+
transform-box: view-box;
|
|
2473
|
+
animation: galaxy-rotate 420s linear infinite;
|
|
2474
|
+
mix-blend-mode: screen;
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2477
|
+
@keyframes galaxy-rotate {
|
|
2478
|
+
to { transform: rotate(360deg); }
|
|
2479
|
+
}
|
|
2480
|
+
|
|
2481
|
+
.galaxy-core {
|
|
2482
|
+
mix-blend-mode: screen;
|
|
2483
|
+
animation: core-breathe 9s ease-in-out infinite alternate;
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
@keyframes core-breathe {
|
|
2487
|
+
from { opacity: 0.5; }
|
|
2488
|
+
to { opacity: 0.9; }
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
.pulse-wrap { transition: opacity 260ms ease; }
|
|
2492
|
+
.pulse-wrap.is-hidden { opacity: 0; }
|
|
2493
|
+
.pulse-wrap.is-dimmed { opacity: 0.08; }
|
|
2494
|
+
|
|
2495
|
+
.edge-pulse {
|
|
2496
|
+
filter: drop-shadow(0 0 4px rgba(157, 196, 255, 0.9));
|
|
2497
|
+
}
|
|
2498
|
+
|
|
2268
2499
|
.orbit-ring {
|
|
2269
2500
|
fill: none;
|
|
2270
2501
|
stroke: var(--text-secondary);
|
|
@@ -2312,6 +2543,64 @@ function renderStyles() {
|
|
|
2312
2543
|
|
|
2313
2544
|
.graph-canvas.has-selection .labels text { opacity: 0.18; transition: opacity 220ms ease; }
|
|
2314
2545
|
|
|
2546
|
+
.graph-3d {
|
|
2547
|
+
position: relative;
|
|
2548
|
+
height: 620px;
|
|
2549
|
+
border-radius: var(--radius-lg);
|
|
2550
|
+
overflow: hidden;
|
|
2551
|
+
background: #000005;
|
|
2552
|
+
cursor: grab;
|
|
2553
|
+
user-select: none;
|
|
2554
|
+
-webkit-user-select: none;
|
|
2555
|
+
touch-action: none;
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2558
|
+
.graph-3d:active { cursor: grabbing; }
|
|
2559
|
+
|
|
2560
|
+
.graph-3d canvas { display: block; }
|
|
2561
|
+
|
|
2562
|
+
.graph3d-labels {
|
|
2563
|
+
position: absolute;
|
|
2564
|
+
inset: 0;
|
|
2565
|
+
pointer-events: none;
|
|
2566
|
+
overflow: hidden;
|
|
2567
|
+
}
|
|
2568
|
+
|
|
2569
|
+
.graph3d-label {
|
|
2570
|
+
position: absolute;
|
|
2571
|
+
left: 0;
|
|
2572
|
+
top: 0;
|
|
2573
|
+
font-family: var(--font-mono);
|
|
2574
|
+
font-size: 11px;
|
|
2575
|
+
color: rgba(222, 230, 248, 0.88);
|
|
2576
|
+
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.95);
|
|
2577
|
+
white-space: nowrap;
|
|
2578
|
+
transition: opacity 220ms ease;
|
|
2579
|
+
will-change: transform;
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
.graph3d-label.is-minor {
|
|
2583
|
+
font-size: 10px;
|
|
2584
|
+
color: rgba(168, 178, 200, 0.6);
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
.graph3d-label.is-selected {
|
|
2588
|
+
color: #FFFFFF;
|
|
2589
|
+
font-weight: 600;
|
|
2590
|
+
}
|
|
2591
|
+
|
|
2592
|
+
.graph-3d-fallback {
|
|
2593
|
+
position: absolute;
|
|
2594
|
+
inset: 0;
|
|
2595
|
+
display: grid;
|
|
2596
|
+
place-items: center;
|
|
2597
|
+
margin: 0;
|
|
2598
|
+
padding: var(--space-4);
|
|
2599
|
+
color: var(--text-secondary);
|
|
2600
|
+
font-size: 13px;
|
|
2601
|
+
text-align: center;
|
|
2602
|
+
}
|
|
2603
|
+
|
|
2315
2604
|
.map-controls-row {
|
|
2316
2605
|
display: flex;
|
|
2317
2606
|
gap: var(--space-2);
|
|
@@ -2421,8 +2710,11 @@ function renderStyles() {
|
|
|
2421
2710
|
.graph-canvas text,
|
|
2422
2711
|
.orbit-spin,
|
|
2423
2712
|
.star-twinkle,
|
|
2713
|
+
.galaxy-spiral,
|
|
2714
|
+
.galaxy-core,
|
|
2424
2715
|
.page.is-active { animation: none; }
|
|
2425
2716
|
.graph-node { transition: none; }
|
|
2717
|
+
.pulses { display: none; }
|
|
2426
2718
|
}
|
|
2427
2719
|
|
|
2428
2720
|
.map-caption {
|
|
@@ -3229,6 +3521,7 @@ function renderReport(summary) {
|
|
|
3229
3521
|
</div>
|
|
3230
3522
|
${renderContractMetadata(copy)}
|
|
3231
3523
|
${renderScript(harnesses, copy)}
|
|
3524
|
+
${renderGraph3DModule(copy)}
|
|
3232
3525
|
</body>
|
|
3233
3526
|
</html>
|
|
3234
3527
|
`;
|