tink-harness 1.9.8 → 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 +12 -0
- package/VERSIONING.md +1 -1
- package/package.json +1 -1
- package/templates/tink/tools/render-harness-health-report.mjs +585 -419
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,18 @@ 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
|
+
|
|
9
21
|
## [1.9.8] - 2026-06-11
|
|
10
22
|
|
|
11
23
|
### 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,70 +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
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
const s4 = (hashString(`gal:${index}:d`) % 10000) / 10000;
|
|
683
|
-
const radius = Math.pow(s1, GALAXY.power) * GALAXY.radius;
|
|
684
|
-
const t = radius / GALAXY.radius;
|
|
685
|
-
const branchAngle = ((index % GALAXY.arms) / GALAXY.arms) * Math.PI * 2;
|
|
686
|
-
const spinAngle = t * GALAXY.spin;
|
|
687
|
-
const randomX = (s2 - 0.5) * 0.42 * radius;
|
|
688
|
-
const randomY = (s3 - 0.5) * 0.42 * radius;
|
|
689
|
-
const totalAngle = branchAngle + spinAngle;
|
|
690
|
-
// inside #FF66FF -> outside #66FFFF, like the reference galaxy
|
|
691
|
-
const color = `rgb(${lerpChannel(255, 102, t)}, ${lerpChannel(102, 255, t)}, 255)`;
|
|
692
|
-
return {
|
|
693
|
-
x: (GALAXY.cx + (Math.cos(totalAngle) * radius + randomX)).toFixed(1),
|
|
694
|
-
y: (GALAXY.cy + (Math.sin(totalAngle) * radius + randomY) * GALAXY.flatten).toFixed(1),
|
|
695
|
-
r: (0.5 + s4 * 1.1).toFixed(1),
|
|
696
|
-
color,
|
|
697
|
-
opacity: (0.12 + s4 * 0.34).toFixed(2)
|
|
698
|
-
};
|
|
699
|
-
});
|
|
700
|
-
const nebulae = Array.from({ length: 7 }, (_, index) => {
|
|
701
|
-
const seed = hashString(`nebula:${index}`);
|
|
702
|
-
return {
|
|
703
|
-
id: index,
|
|
704
|
-
hue: seed % 360,
|
|
705
|
-
x: 80 + (seed % 930),
|
|
706
|
-
y: 60 + ((seed >> 5) % 560),
|
|
707
|
-
r: 70 + ((seed >> 3) % 150),
|
|
708
|
-
opacity: (0.05 + ((seed >> 7) % 8) / 100).toFixed(2)
|
|
709
|
-
};
|
|
710
|
-
});
|
|
711
|
-
const pulses = edges.slice(0, 120).map((edge, index) => {
|
|
712
|
-
const seed = hashString(`pulse:${edge.source}:${edge.target}:${index}`);
|
|
713
|
-
return {
|
|
714
|
-
path: `M ${edge.sourceNode.x.toFixed(1)},${edge.sourceNode.y.toFixed(1)} L ${edge.targetNode.x.toFixed(1)},${edge.targetNode.y.toFixed(1)}`,
|
|
715
|
-
dur: (3.2 + (seed % 42) / 10).toFixed(1),
|
|
716
|
-
begin: -((seed >> 4) % 7000),
|
|
717
|
-
index
|
|
718
|
-
};
|
|
719
|
-
});
|
|
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');
|
|
720
671
|
const mapTitle = copy.knowledgeGraph || copy.harnessMap || 'Harness map';
|
|
721
672
|
const mapEyebrow = copy.harnessMap && copy.harnessMap !== mapTitle ? copy.harnessMap : '';
|
|
722
673
|
return `
|
|
@@ -739,142 +690,10 @@ function renderGraphCanvas(summary, copy) {
|
|
|
739
690
|
</div>
|
|
740
691
|
</div>
|
|
741
692
|
</div>
|
|
742
|
-
<
|
|
743
|
-
<
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
<stop offset="60%" style="stop-color: #05060F"/>
|
|
747
|
-
<stop offset="100%" style="stop-color: #000005"/>
|
|
748
|
-
</radialGradient>
|
|
749
|
-
<radialGradient id="galaxy-core-grad" cx="50%" cy="50%" r="50%">
|
|
750
|
-
<stop offset="0%" style="stop-color: #FFFFFF; stop-opacity: 0.55"/>
|
|
751
|
-
<stop offset="35%" style="stop-color: #C9B8FF; stop-opacity: 0.18"/>
|
|
752
|
-
<stop offset="100%" style="stop-color: #C9B8FF; stop-opacity: 0"/>
|
|
753
|
-
</radialGradient>
|
|
754
|
-
${nebulae.map((nebula) => `
|
|
755
|
-
<radialGradient id="nebula-grad-${nebula.id}" cx="50%" cy="50%" r="50%">
|
|
756
|
-
<stop offset="0%" style="stop-color: hsl(${nebula.hue}, 80%, 60%); stop-opacity: 0.6"/>
|
|
757
|
-
<stop offset="100%" style="stop-color: hsl(${nebula.hue}, 80%, 60%); stop-opacity: 0"/>
|
|
758
|
-
</radialGradient>
|
|
759
|
-
`).join('')}
|
|
760
|
-
<radialGradient id="pulse-grad" cx="50%" cy="50%" r="50%">
|
|
761
|
-
<stop offset="0%" style="stop-color: #FFFFFF; stop-opacity: 1"/>
|
|
762
|
-
<stop offset="45%" style="stop-color: #9DC4FF; stop-opacity: 0.85"/>
|
|
763
|
-
<stop offset="100%" style="stop-color: #5B8DEF; stop-opacity: 0"/>
|
|
764
|
-
</radialGradient>
|
|
765
|
-
${Object.entries(TYPE_COLORS).map(([type, color]) => `
|
|
766
|
-
<radialGradient id="node-grad-${escapeAttr(type)}" cx="32%" cy="28%" r="78%">
|
|
767
|
-
<stop offset="0%" style="stop-color: #FFFFFF; stop-opacity: 0.42"/>
|
|
768
|
-
<stop offset="38%" style="stop-color: ${escapeAttr(color)}; stop-opacity: 0.98"/>
|
|
769
|
-
<stop offset="100%" style="stop-color: ${escapeAttr(color)}; stop-opacity: 0.78"/>
|
|
770
|
-
</radialGradient>
|
|
771
|
-
`).join('')}
|
|
772
|
-
</defs>
|
|
773
|
-
<rect class="graph-bg" width="1090" height="680" fill="url(#graph-bg-grad)"/>
|
|
774
|
-
<g class="starfield" aria-hidden="true">
|
|
775
|
-
${stars.map((star) => `
|
|
776
|
-
<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"` : ''}/>
|
|
777
|
-
`).join('')}
|
|
778
|
-
</g>
|
|
779
|
-
<g class="galaxy-layer" aria-hidden="true">
|
|
780
|
-
${nebulae.map((nebula) => `
|
|
781
|
-
<circle class="nebula" cx="${nebula.x}" cy="${nebula.y}" r="${nebula.r}" fill="url(#nebula-grad-${nebula.id})" opacity="${nebula.opacity}"/>
|
|
782
|
-
`).join('')}
|
|
783
|
-
<g class="galaxy-spiral">
|
|
784
|
-
${galaxyDots.map((dot) => `<circle cx="${dot.x}" cy="${dot.y}" r="${dot.r}" fill="${dot.color}" opacity="${dot.opacity}"/>`).join('')}
|
|
785
|
-
</g>
|
|
786
|
-
<circle class="galaxy-core" cx="${GALAXY.cx}" cy="${GALAXY.cy}" r="120" fill="url(#galaxy-core-grad)"/>
|
|
787
|
-
</g>
|
|
788
|
-
<g id="graph-viewport">
|
|
789
|
-
<g class="edges">
|
|
790
|
-
${edges.map((edge, index) => `
|
|
791
|
-
<line
|
|
792
|
-
class="graph-edge"
|
|
793
|
-
style="--edge-delay: ${Math.min(index * 5, 850)}ms"
|
|
794
|
-
data-source="${escapeAttr(edge.source)}"
|
|
795
|
-
data-target="${escapeAttr(edge.target)}"
|
|
796
|
-
x1="${edge.sourceNode.x.toFixed(1)}"
|
|
797
|
-
y1="${edge.sourceNode.y.toFixed(1)}"
|
|
798
|
-
x2="${edge.targetNode.x.toFixed(1)}"
|
|
799
|
-
y2="${edge.targetNode.y.toFixed(1)}"
|
|
800
|
-
stroke="${escapeAttr(edge.sourceNode.color)}"
|
|
801
|
-
stroke-opacity="0.12"
|
|
802
|
-
stroke-width="${clamp(Number(edge.count || 1), 1, 5)}"
|
|
803
|
-
/>
|
|
804
|
-
`).join('')}
|
|
805
|
-
</g>
|
|
806
|
-
<g class="pulses" aria-hidden="true">
|
|
807
|
-
${pulses.map((pulse) => `
|
|
808
|
-
<g class="pulse-wrap" data-pulse-index="${pulse.index}">
|
|
809
|
-
<circle class="edge-pulse" r="2.6" fill="url(#pulse-grad)" opacity="0">
|
|
810
|
-
<animateMotion dur="${pulse.dur}s" begin="${pulse.begin}ms" repeatCount="indefinite" path="${escapeAttr(pulse.path)}"/>
|
|
811
|
-
<animate attributeName="opacity" values="0;0.95;0.95;0" keyTimes="0;0.12;0.85;1" dur="${pulse.dur}s" begin="${pulse.begin}ms" repeatCount="indefinite"/>
|
|
812
|
-
</circle>
|
|
813
|
-
</g>
|
|
814
|
-
`).join('')}
|
|
815
|
-
</g>
|
|
816
|
-
<g class="orbits" aria-hidden="true">
|
|
817
|
-
${orbitSystems.map((system) => `
|
|
818
|
-
<g class="orbit-system" data-parent="${escapeAttr(system.parentId)}" transform="translate(${system.x.toFixed(1)} ${system.y.toFixed(1)})">
|
|
819
|
-
${system.rings.map((ring) => {
|
|
820
|
-
const dots = Array.from({ length: ring.count }, (_, dotIndex) => {
|
|
821
|
-
const angle = ring.phase + (dotIndex * Math.PI * 2) / ring.count;
|
|
822
|
-
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)})"/>`;
|
|
823
|
-
}).join('');
|
|
824
|
-
return `
|
|
825
|
-
<circle class="orbit-ring" cx="0" cy="0" r="${ring.distance.toFixed(1)}"/>
|
|
826
|
-
<g class="orbit-spin${ring.reverse ? ' is-reverse' : ''}" style="--orbit-dur: ${ring.duration}s">${dots}</g>
|
|
827
|
-
`;
|
|
828
|
-
}).join('')}
|
|
829
|
-
</g>
|
|
830
|
-
`).join('')}
|
|
831
|
-
</g>
|
|
832
|
-
<g class="nodes">
|
|
833
|
-
${nodes.map((node, index) => {
|
|
834
|
-
const seed = hashString(node.id);
|
|
835
|
-
const floatDuration = (5 + (seed % 50) / 10).toFixed(1);
|
|
836
|
-
const floatDelay = -(seed % 4000);
|
|
837
|
-
const floatX = ((seed % 7) - 3).toFixed(1);
|
|
838
|
-
const floatY = (((seed >> 3) % 7) - 3).toFixed(1);
|
|
839
|
-
return `
|
|
840
|
-
<g class="node-float" style="--float-dur: ${floatDuration}s; --float-delay: ${floatDelay}ms; --float-x: ${floatX}px; --float-y: ${floatY}px">
|
|
841
|
-
${node.type === 'harness' && node.radius >= 15 ? `
|
|
842
|
-
<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)})"/>
|
|
843
|
-
` : ''}
|
|
844
|
-
<circle
|
|
845
|
-
class="graph-node ${node.type === 'harness' ? 'is-interactive' : ''}"
|
|
846
|
-
style="--enter-delay: ${Math.min(index * 9, 1100)}ms"
|
|
847
|
-
tabindex="${node.type === 'harness' ? '0' : '-1'}"
|
|
848
|
-
role="${node.type === 'harness' ? 'button' : 'presentation'}"
|
|
849
|
-
aria-label="${escapeAttr(`${copy.tooltipPrefix}: ${node.label}`)}"
|
|
850
|
-
data-node-id="${escapeAttr(node.id)}"
|
|
851
|
-
data-node-type="${escapeAttr(node.type)}"
|
|
852
|
-
data-node-label="${escapeAttr(node.label)}"
|
|
853
|
-
data-node-weight="${escapeAttr(node.weight || 0)}"
|
|
854
|
-
data-core="${node.type === 'harness' || node.type === 'rule' || Number(node.weight || 0) > 1 ? 'true' : 'false'}"
|
|
855
|
-
data-recommendation="${escapeAttr(node.recommendation || '')}"
|
|
856
|
-
cx="${node.x.toFixed(1)}"
|
|
857
|
-
cy="${node.y.toFixed(1)}"
|
|
858
|
-
r="${node.radius.toFixed(1)}"
|
|
859
|
-
fill="url(#node-grad-${escapeAttr(TYPE_COLORS[node.type] ? node.type : 'unknown')})"
|
|
860
|
-
fill-opacity="${node.type === 'harness' ? '1' : '0.85'}"
|
|
861
|
-
stroke="${escapeAttr('var(--text-secondary)')}"
|
|
862
|
-
stroke-opacity="${node.glow ? '0.9' : '0.18'}"
|
|
863
|
-
stroke-width="${node.glow ? '1.8' : '0.8'}"
|
|
864
|
-
>
|
|
865
|
-
<title>${escapeHtml(node.id)}</title>
|
|
866
|
-
</circle>
|
|
867
|
-
</g>
|
|
868
|
-
`;
|
|
869
|
-
}).join('')}
|
|
870
|
-
</g>
|
|
871
|
-
<g class="labels">
|
|
872
|
-
${strongest.map((node) => `
|
|
873
|
-
<text x="${(node.x + node.radius + 7).toFixed(1)}" y="${(node.y + 4).toFixed(1)}">${escapeHtml(node.label)}</text>
|
|
874
|
-
`).join('')}
|
|
875
|
-
</g>
|
|
876
|
-
</g>
|
|
877
|
-
</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>
|
|
878
697
|
<div class="graph-tooltip" id="graph-tooltip" role="status" aria-live="polite"></div>
|
|
879
698
|
<div class="map-caption">
|
|
880
699
|
<span id="graph-status">${escapeHtml(copy.showingAll)}</span>
|
|
@@ -882,8 +701,8 @@ function renderGraphCanvas(summary, copy) {
|
|
|
882
701
|
<span>${escapeHtml(copy.linesRelations)}</span>
|
|
883
702
|
</div>
|
|
884
703
|
<div class="map-legend" aria-label="${escapeAttr(copy.nodeTypes || 'Node types')}">
|
|
885
|
-
${['harness', 'rule', '
|
|
886
|
-
<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>
|
|
887
706
|
`).join('')}
|
|
888
707
|
</div>
|
|
889
708
|
</section>
|
|
@@ -1429,11 +1248,7 @@ function renderScript(harnesses, copy) {
|
|
|
1429
1248
|
const byHarnessId = new Map(harnessData.map((item) => ['harness:' + item.id, item]));
|
|
1430
1249
|
const selectedPanel = document.getElementById('selected-panel');
|
|
1431
1250
|
const graphStatus = document.getElementById('graph-status');
|
|
1432
|
-
const tooltip = document.getElementById('graph-tooltip');
|
|
1433
1251
|
const filterStatus = document.getElementById('recommendation-filter-status');
|
|
1434
|
-
const nodes = Array.from(document.querySelectorAll('.graph-node'));
|
|
1435
|
-
const interactiveNodes = nodes.filter((item) => item.classList.contains('is-interactive'));
|
|
1436
|
-
const edges = Array.from(document.querySelectorAll('.graph-edge'));
|
|
1437
1252
|
const cards = Array.from(document.querySelectorAll('.harness-card'));
|
|
1438
1253
|
const recLabelByFilter = Object.fromEntries(
|
|
1439
1254
|
Array.from(document.querySelectorAll('[data-filter-rec]')).map((button) => [
|
|
@@ -1452,63 +1267,25 @@ function renderScript(harnesses, copy) {
|
|
|
1452
1267
|
const text = String(value ?? '').trim();
|
|
1453
1268
|
return text && text.toLowerCase() !== 'unknown' ? text : (copy.notSet || 'Not set');
|
|
1454
1269
|
};
|
|
1455
|
-
const nodeById = (id) => nodes.find((node) => node.dataset.nodeId === id);
|
|
1456
1270
|
const setStatus = (value) => {
|
|
1457
1271
|
if (graphStatus) graphStatus.textContent = value;
|
|
1458
1272
|
};
|
|
1459
1273
|
const setFilterStatus = (value) => {
|
|
1460
1274
|
if (filterStatus) filterStatus.textContent = value;
|
|
1461
1275
|
};
|
|
1462
|
-
const graphCanvas = document.querySelector('.graph-canvas');
|
|
1463
|
-
const orbitSystems = Array.from(document.querySelectorAll('.orbit-system'));
|
|
1464
|
-
const pulseWraps = Array.from(document.querySelectorAll('.pulse-wrap'));
|
|
1465
1276
|
const defaultSelectedPanel = selectedPanel ? selectedPanel.innerHTML : '';
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
wrap.classList.toggle('is-dimmed', Boolean(hasSelection && !edge.classList.contains('is-related')));
|
|
1474
|
-
});
|
|
1475
|
-
}
|
|
1476
|
-
function syncOrbits() {
|
|
1477
|
-
const hasSelection = graphCanvas && graphCanvas.classList.contains('has-selection');
|
|
1478
|
-
orbitSystems.forEach((system) => {
|
|
1479
|
-
const parent = nodeById(system.dataset.parent);
|
|
1480
|
-
const hidden = !parent || parent.classList.contains('is-hidden') || parent.classList.contains('is-filtered-out');
|
|
1481
|
-
system.classList.toggle('is-hidden', hidden);
|
|
1482
|
-
const related = parent && (parent.classList.contains('is-selected') || parent.classList.contains('is-related'));
|
|
1483
|
-
system.classList.toggle('is-dimmed', Boolean(hasSelection && !related));
|
|
1484
|
-
});
|
|
1485
|
-
syncPulses();
|
|
1486
|
-
}
|
|
1487
|
-
function clearSelection() {
|
|
1488
|
-
nodes.forEach((item) => item.classList.remove('is-selected', 'is-related'));
|
|
1489
|
-
edges.forEach((item) => item.classList.remove('is-related'));
|
|
1490
|
-
cards.forEach((item) => item.classList.remove('is-selected'));
|
|
1491
|
-
if (graphCanvas) graphCanvas.classList.remove('has-selection');
|
|
1492
|
-
syncOrbits();
|
|
1493
|
-
}
|
|
1494
|
-
function selectNode(node) {
|
|
1495
|
-
clearSelection();
|
|
1496
|
-
node.classList.add('is-selected');
|
|
1497
|
-
if (graphCanvas) graphCanvas.classList.add('has-selection');
|
|
1498
|
-
const id = node.dataset.nodeId;
|
|
1499
|
-
const item = byHarnessId.get(id);
|
|
1500
|
-
edges.forEach((edge) => {
|
|
1501
|
-
const related = edge.dataset.source === id || edge.dataset.target === id;
|
|
1502
|
-
edge.classList.toggle('is-related', related);
|
|
1503
|
-
if (related) {
|
|
1504
|
-
const otherId = edge.dataset.source === id ? edge.dataset.target : edge.dataset.source;
|
|
1505
|
-
const other = nodeById(otherId);
|
|
1506
|
-
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;
|
|
1507
1284
|
}
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
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) {
|
|
1512
1289
|
selectedPanel.innerHTML = '<p class="eyebrow">' + esc(copy.selected) + '</p>' +
|
|
1513
1290
|
'<h2>' + esc(item.id) + '</h2>' +
|
|
1514
1291
|
'<p>' + esc((item.reason || copy.clickNode)) + '</p>' +
|
|
@@ -1518,32 +1295,29 @@ function renderScript(harnesses, copy) {
|
|
|
1518
1295
|
'<div><dt>' + esc(copy.uses) + '</dt><dd>' + esc(item.uses) + '</dd></div>' +
|
|
1519
1296
|
'<div><dt>' + esc(copy.score) + '</dt><dd>' + esc(item.score) + '</dd></div>' +
|
|
1520
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>';
|
|
1521
1300
|
}
|
|
1522
|
-
} else if (selectedPanel) {
|
|
1523
|
-
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>';
|
|
1524
1301
|
}
|
|
1525
|
-
|
|
1526
|
-
}
|
|
1302
|
+
};
|
|
1527
1303
|
function selectHarness(id) {
|
|
1528
|
-
const
|
|
1529
|
-
if (
|
|
1530
|
-
|
|
1531
|
-
node.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' });
|
|
1532
|
-
}
|
|
1304
|
+
const nodeId = 'harness:' + id;
|
|
1305
|
+
if (window.__tinkGraph) window.__tinkGraph.select(nodeId);
|
|
1306
|
+
else window.__tinkGraphState.pendingSelect = nodeId;
|
|
1533
1307
|
}
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
const
|
|
1537
|
-
|
|
1538
|
-
|
|
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);
|
|
1539
1319
|
});
|
|
1540
|
-
|
|
1541
|
-
const visibleIds = new Set(nodes.filter((node) => !node.classList.contains('is-hidden')).map((node) => node.dataset.nodeId));
|
|
1542
|
-
edges.forEach((edge) => edge.classList.toggle('is-hidden', mode === 'core' && (!visibleIds.has(edge.dataset.source) || !visibleIds.has(edge.dataset.target))));
|
|
1543
|
-
document.querySelectorAll('.orbit-system').forEach((system) => system.classList.toggle('is-mode-hidden', mode === 'core'));
|
|
1544
|
-
setStatus(mode === 'core' ? copy.coreMode : copy.showingAll);
|
|
1545
|
-
syncOrbits();
|
|
1546
|
-
}
|
|
1320
|
+
});
|
|
1547
1321
|
function filterRecommendation(value, button) {
|
|
1548
1322
|
const alreadyActive = button.classList.contains('active-filter');
|
|
1549
1323
|
document.querySelectorAll('[data-filter-rec]').forEach((item) => {
|
|
@@ -1551,138 +1325,29 @@ function renderScript(harnesses, copy) {
|
|
|
1551
1325
|
item.setAttribute('aria-pressed', 'false');
|
|
1552
1326
|
});
|
|
1553
1327
|
if (alreadyActive) {
|
|
1554
|
-
nodes.forEach((node) => node.classList.remove('is-filtered-out'));
|
|
1555
|
-
edges.forEach((edge) => edge.classList.remove('is-filtered-out'));
|
|
1556
1328
|
cards.forEach((card) => card.classList.remove('is-filtered-out'));
|
|
1329
|
+
window.__tinkGraphState.filter = null;
|
|
1330
|
+
if (window.__tinkGraph) window.__tinkGraph.setFilter(null);
|
|
1557
1331
|
setStatus(copy.showingAll);
|
|
1558
1332
|
setFilterStatus(copy.showingAll);
|
|
1559
|
-
syncOrbits();
|
|
1560
1333
|
return;
|
|
1561
1334
|
}
|
|
1562
1335
|
button.classList.add('active-filter');
|
|
1563
1336
|
button.setAttribute('aria-pressed', 'true');
|
|
1564
|
-
nodes.forEach((node) => {
|
|
1565
|
-
const hide = node.dataset.nodeType === 'harness' && node.dataset.recommendation !== value;
|
|
1566
|
-
node.classList.toggle('is-filtered-out', hide);
|
|
1567
|
-
});
|
|
1568
|
-
const visibleIds = new Set(nodes.filter((node) => !node.classList.contains('is-filtered-out')).map((node) => node.dataset.nodeId));
|
|
1569
|
-
edges.forEach((edge) => edge.classList.toggle('is-filtered-out', !visibleIds.has(edge.dataset.source) || !visibleIds.has(edge.dataset.target)));
|
|
1570
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);
|
|
1571
1340
|
const label = recLabelByFilter[value] || value;
|
|
1572
1341
|
setStatus(copy.filteredTo + ': ' + label);
|
|
1573
1342
|
setFilterStatus(copy.filteredTo + ': ' + label);
|
|
1574
|
-
syncOrbits();
|
|
1575
1343
|
}
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
if (
|
|
1580
|
-
|
|
1581
|
-
selectNode(node);
|
|
1582
|
-
}
|
|
1583
|
-
});
|
|
1584
|
-
node.addEventListener('pointerenter', () => {
|
|
1585
|
-
if (!tooltip) return;
|
|
1586
|
-
const box = node.getBoundingClientRect();
|
|
1587
|
-
const label = copy.tooltipPrefix + ': ' + node.dataset.nodeLabel;
|
|
1588
|
-
const typeLabel = node.dataset.nodeType ? ' - ' + node.dataset.nodeType : '';
|
|
1589
|
-
tooltip.textContent = label + typeLabel;
|
|
1590
|
-
tooltip.style.left = (Math.min(Math.ceil(box.right + 12), window.innerWidth - 220)) + 'px';
|
|
1591
|
-
tooltip.style.top = (Math.max(Math.ceil(box.top - 12), 12)) + 'px';
|
|
1592
|
-
tooltip.classList.add('is-visible');
|
|
1593
|
-
});
|
|
1594
|
-
node.addEventListener('pointerleave', () => {
|
|
1595
|
-
if (tooltip) {
|
|
1596
|
-
tooltip.classList.remove('is-visible');
|
|
1597
|
-
tooltip.textContent = '';
|
|
1598
|
-
}
|
|
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);
|
|
1599
1349
|
});
|
|
1600
1350
|
});
|
|
1601
|
-
document.querySelectorAll('[data-mode]').forEach((button) => {
|
|
1602
|
-
button.addEventListener('click', () => applyMode(button.dataset.mode));
|
|
1603
|
-
});
|
|
1604
|
-
const graphSvg = document.querySelector('.graph-canvas');
|
|
1605
|
-
const graphViewport = document.getElementById('graph-viewport');
|
|
1606
|
-
if (graphSvg && graphViewport) {
|
|
1607
|
-
const view = { x: 0, y: 0, k: 1 };
|
|
1608
|
-
graphViewport.style.transformOrigin = '0 0';
|
|
1609
|
-
const applyView = () => {
|
|
1610
|
-
graphViewport.style.transform = 'translate(' + view.x + 'px, ' + view.y + 'px) scale(' + view.k + ')';
|
|
1611
|
-
};
|
|
1612
|
-
const svgPoint = (event) => {
|
|
1613
|
-
const pt = graphSvg.createSVGPoint();
|
|
1614
|
-
pt.x = event.clientX;
|
|
1615
|
-
pt.y = event.clientY;
|
|
1616
|
-
return pt.matrixTransform(graphSvg.getScreenCTM().inverse());
|
|
1617
|
-
};
|
|
1618
|
-
const zoomAt = (factor, cx, cy) => {
|
|
1619
|
-
const k = Math.min(5, Math.max(0.4, view.k * factor));
|
|
1620
|
-
const real = k / view.k;
|
|
1621
|
-
view.x = cx - (cx - view.x) * real;
|
|
1622
|
-
view.y = cy - (cy - view.y) * real;
|
|
1623
|
-
view.k = k;
|
|
1624
|
-
applyView();
|
|
1625
|
-
};
|
|
1626
|
-
const resetView = () => {
|
|
1627
|
-
graphViewport.classList.add('is-resetting');
|
|
1628
|
-
view.x = 0; view.y = 0; view.k = 1;
|
|
1629
|
-
applyView();
|
|
1630
|
-
setTimeout(() => graphViewport.classList.remove('is-resetting'), 360);
|
|
1631
|
-
};
|
|
1632
|
-
graphSvg.addEventListener('wheel', (event) => {
|
|
1633
|
-
event.preventDefault();
|
|
1634
|
-
const point = svgPoint(event);
|
|
1635
|
-
zoomAt(event.deltaY < 0 ? 1.15 : 1 / 1.15, point.x, point.y);
|
|
1636
|
-
}, { passive: false });
|
|
1637
|
-
let panState = null;
|
|
1638
|
-
graphSvg.addEventListener('pointerdown', (event) => {
|
|
1639
|
-
if (event.button !== 0) return;
|
|
1640
|
-
panState = { x: event.clientX, y: event.clientY, moved: false };
|
|
1641
|
-
graphSvg.setPointerCapture(event.pointerId);
|
|
1642
|
-
});
|
|
1643
|
-
graphSvg.addEventListener('pointermove', (event) => {
|
|
1644
|
-
if (!panState) return;
|
|
1645
|
-
const dx = event.clientX - panState.x;
|
|
1646
|
-
const dy = event.clientY - panState.y;
|
|
1647
|
-
if (!panState.moved && Math.abs(dx) + Math.abs(dy) < 3) return;
|
|
1648
|
-
panState.moved = true;
|
|
1649
|
-
graphSvg.classList.add('is-panning');
|
|
1650
|
-
const scale = 1090 / graphSvg.clientWidth;
|
|
1651
|
-
view.x += dx * scale;
|
|
1652
|
-
view.y += dy * scale;
|
|
1653
|
-
panState.x = event.clientX;
|
|
1654
|
-
panState.y = event.clientY;
|
|
1655
|
-
applyView();
|
|
1656
|
-
});
|
|
1657
|
-
let suppressClick = false;
|
|
1658
|
-
const endPan = () => {
|
|
1659
|
-
suppressClick = Boolean(panState && panState.moved);
|
|
1660
|
-
panState = null;
|
|
1661
|
-
graphSvg.classList.remove('is-panning');
|
|
1662
|
-
};
|
|
1663
|
-
graphSvg.addEventListener('pointerup', endPan);
|
|
1664
|
-
graphSvg.addEventListener('pointercancel', endPan);
|
|
1665
|
-
graphSvg.addEventListener('click', (event) => {
|
|
1666
|
-
if (suppressClick) {
|
|
1667
|
-
suppressClick = false;
|
|
1668
|
-
return;
|
|
1669
|
-
}
|
|
1670
|
-
if (event.target.classList.contains('graph-bg') || event.target.closest('.starfield') || event.target.closest('.galaxy-layer')) {
|
|
1671
|
-
clearSelection();
|
|
1672
|
-
if (selectedPanel && defaultSelectedPanel) selectedPanel.innerHTML = defaultSelectedPanel;
|
|
1673
|
-
}
|
|
1674
|
-
});
|
|
1675
|
-
graphSvg.addEventListener('dblclick', (event) => {
|
|
1676
|
-
event.preventDefault();
|
|
1677
|
-
resetView();
|
|
1678
|
-
});
|
|
1679
|
-
document.querySelectorAll('[data-zoom]').forEach((button) => {
|
|
1680
|
-
button.addEventListener('click', () => {
|
|
1681
|
-
if (button.dataset.zoom === 'reset') return resetView();
|
|
1682
|
-
zoomAt(button.dataset.zoom === 'in' ? 1.3 : 1 / 1.3, 545, 340);
|
|
1683
|
-
});
|
|
1684
|
-
});
|
|
1685
|
-
}
|
|
1686
1351
|
const VALID_TABS = ['home', 'harnesses', 'memory', 'graph', 'activity'];
|
|
1687
1352
|
const navLinks = Array.from(document.querySelectorAll('.nav a[data-tab]'));
|
|
1688
1353
|
const pages = Array.from(document.querySelectorAll('.page'));
|
|
@@ -1734,6 +1399,448 @@ function renderScript(harnesses, copy) {
|
|
|
1734
1399
|
`;
|
|
1735
1400
|
}
|
|
1736
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
|
+
|
|
1737
1844
|
function renderStyles() {
|
|
1738
1845
|
return `<style>
|
|
1739
1846
|
:root {
|
|
@@ -2436,6 +2543,64 @@ function renderStyles() {
|
|
|
2436
2543
|
|
|
2437
2544
|
.graph-canvas.has-selection .labels text { opacity: 0.18; transition: opacity 220ms ease; }
|
|
2438
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
|
+
|
|
2439
2604
|
.map-controls-row {
|
|
2440
2605
|
display: flex;
|
|
2441
2606
|
gap: var(--space-2);
|
|
@@ -3356,6 +3521,7 @@ function renderReport(summary) {
|
|
|
3356
3521
|
</div>
|
|
3357
3522
|
${renderContractMetadata(copy)}
|
|
3358
3523
|
${renderScript(harnesses, copy)}
|
|
3524
|
+
${renderGraph3DModule(copy)}
|
|
3359
3525
|
</body>
|
|
3360
3526
|
</html>
|
|
3361
3527
|
`;
|