From 6a92febd3a401f5c2e4111bf1147fe8eac63d4be Mon Sep 17 00:00:00 2001 From: gogocat Date: Tue, 7 Jul 2026 23:26:05 +0300 Subject: [PATCH 01/17] feat(idef0): 3D isometric exploded-pyramid spike (Threlte) + R1 geometry Throwaway /iso-spike route de-risking a true-3D IDEF0 exploded-pyramid map view. Threlte v8 + three; R1 non-centered nesting (child plane explodes under the parent zone-box, not centered), dashed MeshLine frustum connectors (attenuate=false), ICOM boundary arrows via real classifyIcom (ADR-007). Spike-scoped (// TODO(spike)); pre-RFC. Co-Authored-By: Claude Opus 4.8 (1M context) --- template/package-lock.json | 246 +++++++- template/package.json | 6 +- template/src/routes/iso-spike/+page.svelte | 129 +++++ template/src/routes/iso-spike/IsoScene.svelte | 524 ++++++++++++++++++ 4 files changed, 903 insertions(+), 2 deletions(-) create mode 100644 template/src/routes/iso-spike/+page.svelte create mode 100644 template/src/routes/iso-spike/IsoScene.svelte diff --git a/template/package-lock.json b/template/package-lock.json index d21a80d..445f085 100644 --- a/template/package-lock.json +++ b/template/package-lock.json @@ -11,6 +11,8 @@ "@lucide/svelte": "^1.14.0", "@neodrag/svelte": "3.0.0-next.8", "@sveltejs/kit": "^2.59.0", + "@threlte/core": "^8.5.16", + "@threlte/extras": "^9.21.0", "bits-ui": "^2.18.1", "d3-force": "^3.0.0", "d3-hierarchy": "^3.1.2", @@ -23,7 +25,8 @@ "rehype-highlight": "^7.0.2", "svelte": "^5.55.5", "svelte-exmarkdown": "^5.0.2", - "svelte-sonner": "^1.1.1" + "svelte-sonner": "^1.1.1", + "three": "^0.185.1" }, "devDependencies": { "@sveltejs/adapter-node": "^5.5.4", @@ -37,6 +40,7 @@ "@types/d3-zoom": "^3.0.8", "@types/dompurify": "^3.0.5", "@types/node": "^20.17.0", + "@types/three": "^0.185.0", "happy-dom": "^20.9.0", "svelte-check": "^4.4.7", "typescript": "^6.0.3", @@ -47,6 +51,12 @@ "node": "^20.19.0 || >=22.12.0" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@emnapi/core": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", @@ -1012,6 +1022,55 @@ "tslib": "^2.8.0" } }, + "node_modules/@threejs-kit/instanced-sprite-mesh": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/@threejs-kit/instanced-sprite-mesh/-/instanced-sprite-mesh-2.5.1.tgz", + "integrity": "sha512-pmt1ALRhbHhCJQTj2FuthH6PeLIeaM4hOuS2JO3kWSwlnvx/9xuUkjFR3JOi/myMqsH7pSsLIROSaBxDfttjeA==", + "dependencies": { + "diet-sprite": "^0.0.1", + "earcut": "^2.2.4", + "maath": "^0.10.7", + "three-instanced-uniforms-mesh": "^0.52.4", + "troika-three-utils": "^0.52.4" + }, + "peerDependencies": { + "three": ">=0.170.0" + } + }, + "node_modules/@threlte/core": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/@threlte/core/-/core-8.5.16.tgz", + "integrity": "sha512-+aa9Zgz0/Qat82H0hTm1pTEtS2BIP1Nfu7pKaei+3+TvDqER0YnZ4hdgI9fYU9Z5r1lrzY3JxMyrdISp4B0kfw==", + "license": "MIT", + "peerDependencies": { + "svelte": ">=5", + "three": ">=0.160" + } + }, + "node_modules/@threlte/extras": { + "version": "9.21.0", + "resolved": "https://registry.npmjs.org/@threlte/extras/-/extras-9.21.0.tgz", + "integrity": "sha512-ZkBx4IH+7vcxVM09Rex9XJNMKKbQt4JZ5J/3TnBd/Fge8IzSgl0ydgn70riDf/8tDLMDID6VdZPPrlr5vCHHJA==", + "license": "MIT", + "dependencies": { + "@threejs-kit/instanced-sprite-mesh": "^2.5.1", + "camera-controls": "^3.1.2", + "three-mesh-bvh": "^0.9.1", + "three-perf": "^1.0.11", + "three-viewport-gizmo": "^2.2.0", + "troika-three-text": "^0.52.4" + }, + "peerDependencies": { + "svelte": ">=5", + "three": ">=0.160" + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -1215,6 +1274,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.185.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.0.tgz", + "integrity": "sha512-O2Uy8Cj4Nonr8dWUUbifMdPe8B0Mq7EdOHb89S4+kjUw/KhbjTZrUuYlrQ1bpUKG+EP9QJnN7qNxbHGlGoLHMA==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -1227,6 +1306,12 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@types/whatwg-mimetype": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", @@ -1423,6 +1508,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bits-ui": { "version": "2.18.1", "resolved": "https://registry.npmjs.org/bits-ui/-/bits-ui-2.18.1.tgz", @@ -1447,6 +1541,19 @@ "svelte": "^5.33.0" } }, + "node_modules/camera-controls": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", + "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==", + "license": "MIT", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.5.1" + }, + "peerDependencies": { + "three": ">=0.126.1" + } + }, "node_modules/ccount": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", @@ -1797,6 +1904,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/diet-sprite": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/diet-sprite/-/diet-sprite-0.0.1.tgz", + "integrity": "sha512-zSHI2WDAn1wJqJYxcmjWfJv3Iw8oL9reQIbEyx2x2/EZ4/qmUTIo8/5qOCurnAcq61EwtJJaZ0XTy2NRYqpB5A==", + "license": "ISC" + }, "node_modules/dompurify": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", @@ -1806,6 +1919,12 @@ "@types/trusted-types": "^2.0.7" } }, + "node_modules/earcut": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-2.2.4.tgz", + "integrity": "sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ==", + "license": "ISC" + }, "node_modules/entities": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", @@ -1911,6 +2030,12 @@ } } }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "license": "MIT" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2359,6 +2484,16 @@ "lz-string": "bin/bin.js" } }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2600,6 +2735,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "license": "MIT" + }, "node_modules/micromark": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", @@ -3373,6 +3514,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -3711,6 +3861,55 @@ "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==", "license": "MIT" }, + "node_modules/three": { + "version": "0.185.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.185.1.tgz", + "integrity": "sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==", + "license": "MIT" + }, + "node_modules/three-instanced-uniforms-mesh": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/three-instanced-uniforms-mesh/-/three-instanced-uniforms-mesh-0.52.4.tgz", + "integrity": "sha512-YwDBy05hfKZQtU+Rp0KyDf9yH4GxfhxMbVt9OYruxdgLfPwmDG5oAbGoW0DrKtKZSM3BfFcCiejiOHCjFBTeng==", + "license": "MIT", + "dependencies": { + "troika-three-utils": "^0.52.4" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/three-mesh-bvh": { + "version": "0.9.10", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.9.10.tgz", + "integrity": "sha512-UOlTgPIeqUURcwaG8knxvBaruwZlC4X3/WSHEFO7rYvMVv/YNUrkfFEszvfj36pXV88dCHoHNnIp0PifkirnTQ==", + "license": "MIT", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/three-perf": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/three-perf/-/three-perf-1.0.11.tgz", + "integrity": "sha512-OgBpZjwL+csQKGKZjpkH/QHdbGFMxqngMbSEJeSnVNfXDYd6On7WHNv/GhUZH4YxIpNMwMahBWrNnsJvnbSJHQ==", + "license": "MIT", + "dependencies": { + "troika-three-text": "^0.52.0", + "tweakpane": "^3.1.10" + }, + "peerDependencies": { + "three": ">=0.170" + } + }, + "node_modules/three-viewport-gizmo": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/three-viewport-gizmo/-/three-viewport-gizmo-2.2.0.tgz", + "integrity": "sha512-Jo9Liur1rUmdKk75FZumLU/+hbF+RtJHi1qsKZpntjKlCYScK6tjbYoqvJ9M+IJphrlQJF5oReFW7Sambh0N4Q==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.162.0 <1.0.0" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3773,6 +3972,36 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "license": "MIT", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==", + "license": "MIT" + }, "node_modules/trough": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", @@ -3789,6 +4018,15 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tweakpane": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/tweakpane/-/tweakpane-3.1.10.tgz", + "integrity": "sha512-rqwnl/pUa7+inhI2E9ayGTqqP0EPOOn/wVvSWjZsRbZUItzNShny7pzwL3hVlaN4m9t/aZhsP0aFQ9U5VVR2VQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/cocopon" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -4125,6 +4363,12 @@ } } }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==", + "license": "MIT" + }, "node_modules/whatwg-mimetype": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", diff --git a/template/package.json b/template/package.json index ca5ed1b..e5ce672 100644 --- a/template/package.json +++ b/template/package.json @@ -26,6 +26,7 @@ "@types/d3-zoom": "^3.0.8", "@types/dompurify": "^3.0.5", "@types/node": "^20.17.0", + "@types/three": "^0.185.0", "happy-dom": "^20.9.0", "svelte-check": "^4.4.7", "typescript": "^6.0.3", @@ -36,6 +37,8 @@ "@lucide/svelte": "^1.14.0", "@neodrag/svelte": "3.0.0-next.8", "@sveltejs/kit": "^2.59.0", + "@threlte/core": "^8.5.16", + "@threlte/extras": "^9.21.0", "bits-ui": "^2.18.1", "d3-force": "^3.0.0", "d3-hierarchy": "^3.1.2", @@ -48,7 +51,8 @@ "rehype-highlight": "^7.0.2", "svelte": "^5.55.5", "svelte-exmarkdown": "^5.0.2", - "svelte-sonner": "^1.1.1" + "svelte-sonner": "^1.1.1", + "three": "^0.185.1" }, "overrides": { "cookie": ">=0.7.0" diff --git a/template/src/routes/iso-spike/+page.svelte b/template/src/routes/iso-spike/+page.svelte new file mode 100644 index 0000000..16cacd0 --- /dev/null +++ b/template/src/routes/iso-spike/+page.svelte @@ -0,0 +1,129 @@ + + +
+ {#if rootBranch.kind === 'loading'} +

Loading map…

+ {:else if rootBranch.kind === 'empty'} +

+ No .forgeplan/map/map.json found — run the forgeplan-map-pack pipeline first. +

+ {:else if rootBranch.kind === 'error'} +

Failed to load map: {rootBranch.message}

+ {:else} + + + +
+
iso-spike — 3D layered map (throwaway)
+
drag to orbit · scroll to zoom · click a box to descend
+ {#if lastDescend} +
descended into: {lastDescend.id} — {lastDescend.label}
+ {/if} +
+ {/if} +
+ + diff --git a/template/src/routes/iso-spike/IsoScene.svelte b/template/src/routes/iso-spike/IsoScene.svelte new file mode 100644 index 0000000..58b2e98 --- /dev/null +++ b/template/src/routes/iso-spike/IsoScene.svelte @@ -0,0 +1,524 @@ + + + ref.lookAt(0, -PLANE_GAP, 0)} +> + + + + + + +{#each planes as plane (plane.label)} + + + + + + + + {#each plane.boxes as box (box.id)} + {#if box.kind === 'zone'} + + handleClick(box)} + > + + + + + {:else} + handleClick(box)} + > + + + + {/if} + {/each} +{/each} + +{#each connectorGroups as group (group.id)} + {#each group.segments as seg (seg.id)} + + + + + {/each} +{/each} + +{#each icomArrows as pair (pair.id)} + + 1 - p} /> + + + + 1 - p} /> + + +{/each} From f8a4e2bff7e6e55aa4d2c89732683621defc6a94 Mon Sep 17 00:00:00 2001 From: gogocat Date: Wed, 8 Jul 2026 00:30:30 +0300 Subject: [PATCH 02/17] wip(idef0): SOLID decomposition of iso view + material/relayer/hover stages Checkpoint of the workflow build: IsoScene monolith split into ui/ (12 components), lib/ (4), model/ (1). Matte material, dynamic re-layering + depthWindow accordion, node & layer hover-cards. 4 known svelte-check implicit-any errors to fix next. Spike-scoped. Co-Authored-By: Claude Opus 4.8 (1M context) --- template/src/routes/iso-spike/+page.svelte | 88 ++- template/src/routes/iso-spike/IsoScene.svelte | 629 +++++------------- .../src/routes/iso-spike/lib/iso-materials.ts | 112 ++++ .../routes/iso-spike/lib/iso-projection.ts | 450 +++++++++++++ .../src/routes/iso-spike/lib/leader-line.ts | 39 ++ template/src/routes/iso-spike/lib/motion.ts | 16 + .../iso-spike/model/iso-view-state.svelte.ts | 431 ++++++++++++ .../routes/iso-spike/ui/IsoA11yProxy.svelte | 80 +++ .../routes/iso-spike/ui/IsoControls.svelte | 66 ++ .../iso-spike/ui/IsoDeeperMarker.svelte | 55 ++ .../src/routes/iso-spike/ui/IsoFrustum.svelte | 44 ++ .../routes/iso-spike/ui/IsoIcomArrows.svelte | 61 ++ .../routes/iso-spike/ui/IsoLayerCard.svelte | 100 +++ .../routes/iso-spike/ui/IsoLeaderLine.svelte | 132 ++++ .../src/routes/iso-spike/ui/IsoNodeBox.svelte | 54 ++ .../routes/iso-spike/ui/IsoNodeCard.svelte | 141 ++++ .../src/routes/iso-spike/ui/IsoPlane.svelte | 127 ++++ .../routes/iso-spike/ui/IsoSliverPlane.svelte | 29 + .../routes/iso-spike/ui/IsoZoneFrame.svelte | 55 ++ 19 files changed, 2212 insertions(+), 497 deletions(-) create mode 100644 template/src/routes/iso-spike/lib/iso-materials.ts create mode 100644 template/src/routes/iso-spike/lib/iso-projection.ts create mode 100644 template/src/routes/iso-spike/lib/leader-line.ts create mode 100644 template/src/routes/iso-spike/lib/motion.ts create mode 100644 template/src/routes/iso-spike/model/iso-view-state.svelte.ts create mode 100644 template/src/routes/iso-spike/ui/IsoA11yProxy.svelte create mode 100644 template/src/routes/iso-spike/ui/IsoControls.svelte create mode 100644 template/src/routes/iso-spike/ui/IsoDeeperMarker.svelte create mode 100644 template/src/routes/iso-spike/ui/IsoFrustum.svelte create mode 100644 template/src/routes/iso-spike/ui/IsoIcomArrows.svelte create mode 100644 template/src/routes/iso-spike/ui/IsoLayerCard.svelte create mode 100644 template/src/routes/iso-spike/ui/IsoLeaderLine.svelte create mode 100644 template/src/routes/iso-spike/ui/IsoNodeBox.svelte create mode 100644 template/src/routes/iso-spike/ui/IsoNodeCard.svelte create mode 100644 template/src/routes/iso-spike/ui/IsoPlane.svelte create mode 100644 template/src/routes/iso-spike/ui/IsoSliverPlane.svelte create mode 100644 template/src/routes/iso-spike/ui/IsoZoneFrame.svelte diff --git a/template/src/routes/iso-spike/+page.svelte b/template/src/routes/iso-spike/+page.svelte index 16cacd0..ca8a868 100644 --- a/template/src/routes/iso-spike/+page.svelte +++ b/template/src/routes/iso-spike/+page.svelte @@ -1,6 +1,12 @@
@@ -70,15 +108,22 @@

Failed to load map: {rootBranch.message}

{:else} - +
iso-spike — 3D layered map (throwaway)
-
drag to orbit · scroll to zoom · click a box to descend
+
drag to orbit · scroll to zoom · click a drillable box to descend
{#if lastDescend}
descended into: {lastDescend.id} — {lastDescend.label}
{/if}
+ + 1} + onAscend={ascend} + /> {/if}
@@ -100,9 +145,12 @@ } .hud { + /* top-RIGHT (not left): LevelBreadcrumb owns top-left (RFC-031 Phase 4 + default position) — this avoids the two overlays overlapping once a + descend makes the breadcrumb appear. */ position: absolute; top: 16px; - left: 16px; + right: 16px; padding: 10px 14px; background: var(--bg-1); border: 1px solid var(--line, var(--fg-4)); diff --git a/template/src/routes/iso-spike/IsoScene.svelte b/template/src/routes/iso-spike/IsoScene.svelte index 58b2e98..efe5802 100644 --- a/template/src/routes/iso-spike/IsoScene.svelte +++ b/template/src/routes/iso-spike/IsoScene.svelte @@ -1,430 +1,145 @@ @@ -442,83 +157,43 @@ -{#each planes as plane (plane.label)} - - - - - - - - {#each plane.boxes as box (box.id)} - {#if box.kind === 'zone'} - - handleClick(box)} - > - - - - - {:else} - handleClick(box)} - > - - - - {/if} - {/each} +{#each windowedPlanes as plane (plane.id)} + {#if plane.mode === 'expanded'} + + armDwell({ kind: 'plane', planeId: plane.id, depthIndex: plane.depthIndex })} + onPlanePointerLeave={() => + disarmDwell({ kind: 'plane', planeId: plane.id, depthIndex: plane.depthIndex })} + onNodePointerEnter={(box) => armDwell({ kind: 'node', id: box.id })} + onNodePointerLeave={(box) => disarmDwell({ kind: 'node', id: box.id })} + /> + {:else} + + {/if} {/each} {#each connectorGroups as group (group.id)} - {#each group.segments as seg (seg.id)} - - - - - {/each} + {/each} -{#each icomArrows as pair (pair.id)} - - 1 - p} /> - - - - 1 - p} /> - - -{/each} + + + +{#if hasDeeper && deepestPlane} + +{/if} diff --git a/template/src/routes/iso-spike/lib/iso-materials.ts b/template/src/routes/iso-spike/lib/iso-materials.ts new file mode 100644 index 0000000..2a9d7fc --- /dev/null +++ b/template/src/routes/iso-spike/lib/iso-materials.ts @@ -0,0 +1,112 @@ +// TODO(iso-promote): promote to widgets/iso-map + move shared drill logic to +// entities on graduation (see .claude/rules/10-comments-policy.md). +// +// Token -> THREE.Color reader + tuned material constants for the iso-spike. +// THEME-REACTIVE: `readIsoColors` must be called from inside a `$derived.by` +// that ALSO reads `themeStore.tick` (see IsoScene.svelte), fixing the former +// "read once at mount, not reactive to theme" TODO — colors recompute every +// time the theme toggles instead of freezing at first paint. +import * as THREE from "three"; + +export interface IsoColorTokens { + /** --accent */ + accent: THREE.Color; + /** --accent-soft */ + accentSoft: THREE.Color; + /** --bg-2 */ + plate: THREE.Color; + /** --fg-2 */ + node: THREE.Color; +} + +const FALLBACKS = { + accent: "#ff5a1f", + accentSoft: "#ff8a5b", + plate: "#141414", + node: "#a3a3a3", +} as const; + +function readCssColor(varName: string, fallback: string): THREE.Color { + if (typeof document === "undefined") return new THREE.Color(fallback); + const val = getComputedStyle(document.documentElement) + .getPropertyValue(varName) + .trim(); + return new THREE.Color(val || fallback); +} + +export function readIsoColors(): IsoColorTokens { + return { + accent: readCssColor("--accent", FALLBACKS.accent), + accentSoft: readCssColor("--accent-soft", FALLBACKS.accentSoft), + plate: readCssColor("--bg-2", FALLBACKS.plate), + node: readCssColor("--fg-2", FALLBACKS.node), + }; +} + +// --- Scout-1: matte paper-sheet zone fill -------------------------------- +// Zone fill used to be MeshBasicMaterial opacity 0.02 with NO color +// (effectively invisible, kept alive only so raycast picking still worked). +// Now the fill is bound to the SAME color driving (see +// IsoZoneFrame.svelte) at a low-but-visible opacity, so zones read as thin +// matte sheets tinted in their own outline color. +export const ZONE_FILL_OPACITY = 0.16; +// polygonOffset (paired with depthWrite:false on the fill material) keeps +// the coplanar fill + outline from z-fighting. +export const ZONE_POLYGON_OFFSET_FACTOR = -1; +export const ZONE_POLYGON_OFFSET_UNITS = -1; + +// Per-level floor "plate" — thinner (paper-like) and faintly tinted. +export const PLATE_THICKNESS = 0.12; +export const PLATE_Y_OFFSET = PLATE_THICKNESS / 2 + 0.02; +export const PLATE_OPACITY = 0.28; + +// --- Stage 3: plane/sheet hover-dwell emphasis --------------------------- +// Boosts the plate's own fill opacity (on top of the existing depth +// falloff) and gates a bright, non-desaturated outline (see +// IsoPlane.svelte) while a plane is the current hover-dwell target. +export const PLATE_EMPHASIS_OPACITY_MULT = 1.9; + +// Monotonic depth falloff: shallower planes (index 0 = root) keep full +// tint; deeper planes dim toward PLANE_FALLOFF_MIN so the exploded stack +// reads front-to-back. +export const PLANE_FALLOFF_STEP = 0.22; +export const PLANE_FALLOFF_MIN = 0.45; + +/** Opacity multiplier for a plane at `index` (0 = shallowest/root). */ +export function planeFalloff(index: number): number { + return Math.max(PLANE_FALLOFF_MIN, 1 - index * PLANE_FALLOFF_STEP); +} + +// Desaturation companion to the opacity falloff above — deeper planes lose +// saturation toward neutral gray, on top of dimming. +export const PLANE_DESATURATION_STEP = 0.18; +export const PLANE_DESATURATION_MAX = 0.6; + +/** Desaturates `color` toward neutral gray as depth `index` increases. + * Returns a NEW THREE.Color; never mutates the input. */ +export function desaturateForDepth( + color: THREE.Color, + index: number, +): THREE.Color { + const amount = Math.min( + PLANE_DESATURATION_MAX, + index * PLANE_DESATURATION_STEP, + ); + if (amount <= 0) return color.clone(); + const hsl = { h: 0, s: 0, l: 0 }; + color.getHSL(hsl); + return new THREE.Color().setHSL(hsl.h, hsl.s * (1 - amount), hsl.l); +} + +// --- Fine dashed lines (frustum connectors + ICOM boundary arrows) ------- +// Thinner, higher dash frequency, shorter dash segments than the original +// fat-dash pass — a fine dotted look instead of chunky dashes. +export const CONNECTOR_LINE_WIDTH = 0.8; +export const CONNECTOR_LINE_OPACITY = 0.85; +export const CONNECTOR_DASH_ARRAY = 0.06; +export const CONNECTOR_DASH_RATIO = 0.65; + +export const ICOM_ARROW_WIDTH = 1.2; +export const ICOM_ARROW_OPACITY = 0.95; +export const ICOM_ARROW_DASH_ARRAY = 0.06; +export const ICOM_ARROW_DASH_RATIO = 0.65; diff --git a/template/src/routes/iso-spike/lib/iso-projection.ts b/template/src/routes/iso-spike/lib/iso-projection.ts new file mode 100644 index 0000000..cd2da2d --- /dev/null +++ b/template/src/routes/iso-spike/lib/iso-projection.ts @@ -0,0 +1,450 @@ +// TODO(iso-promote): promote to widgets/iso-map + move shared drill logic to +// entities on graduation (see .claude/rules/10-comments-policy.md). +// +// Pure geometry/projection helpers for the iso-spike 3D layered map. No +// Svelte, no THREE materials — only math + plain data (THREE.Vector3 is a +// math primitive, not a material). Stage 2 (RFC-031-style generalization): +// every function here now operates over an ARBITRARY-length chain of +// already-resolved MapDocuments (one per altitude) instead of Stage 1's +// hardcoded root/layer/derived triple — drill-derivation itself +// (deriveSubDocument / emitted-layer preference) lives entirely in +// model/iso-view-state.svelte.ts's `docsForRoot`; this file only turns a +// resolved `MapDocument[]` + its focus-id chain into 3D layout data. +import * as THREE from "three"; +import { computeComposedLayout } from "@/entities/map/lib/composed-layout"; +import type { MapDocument } from "@/entities/map"; +import { + classifyIcom, + icomToSide, + isCanonicalRelation, +} from "@/shared/lib/idef0"; +import type { IcomSide } from "@/shared/lib/idef0"; + +export const SCALE = 0.06; +export const PLANE_GAP = 16; +// IDEF0 read: a zone is a thin outlined FRAME (a region boundary), not a +// filled volume — ZONE_FRAME_H is just enough height to give a box +// to outline. NODE_BOX_H is the readable content and stays much taller. +export const ZONE_FRAME_H = 0.5; +export const NODE_BOX_H = 1.2; +export const NODE_FOOTPRINT = 0.82; +export const ICOM_ARROW_LEN = 1.4; + +// Stage 2 — accordion collapse: how tightly collapsed ancestor "sliver" +// planes stack above the expanded window (much smaller than PLANE_GAP, so +// a long drill history still compresses into a small visual footprint +// "near the focus" instead of receding to infinity). +export const SLIVER_GAP = 1.6; + +export interface BoxSpec { + id: string; + label: string; + kind: "zone" | "node"; + x: number; + z: number; + w: number; + d: number; +} + +export interface PlaneSpec { + /** Stable identity across renders (keyed {#each}) — "root", or the + * dot-free join of the focus chain up to and including this depth. */ + id: string; + /** The focusId that produced this plane (the zone/mega clicked into the + * PARENT plane to reveal this one). null for the root plane. */ + focusId: string | null; + label: string; + y: number; + /** World (x,z) of the parent zone-box this plane funnels under — (0,0) + * for the root plane. See `computePlanesForDocs` below (FIX R1). */ + originX: number; + originZ: number; + boxes: BoxSpec[]; + plateW: number; + plateD: number; +} + +export interface WindowedPlane extends PlaneSpec { + /** "expanded" = fully rendered (plate + boxes, clickable if deepest); + * "sliver" = collapsed ancestor, rendered as a thin bare plate only. */ + mode: "expanded" | "sliver"; + /** Absolute index into the full (unwindowed) docsByDepth/planes chain — + * 0 is always the root, regardless of how the window is currently sized. */ + depthIndex: number; +} + +export function boxesForDoc(doc: MapDocument): { + boxes: BoxSpec[]; + plateW: number; + plateD: number; +} { + const layout = computeComposedLayout(doc); + const cx = layout.width / 2; + const cz = layout.height / 2; + const boxes: BoxSpec[] = []; + for (const zone of doc.zones) { + const rect = layout.zoneRects.get(zone.id); + if (!rect) continue; + boxes.push({ + id: zone.id, + label: zone.label, + kind: "zone", + x: (rect.x + rect.w / 2 - cx) * SCALE, + z: (rect.y + rect.h / 2 - cz) * SCALE, + w: Math.max(rect.w * SCALE, 1), + d: Math.max(rect.h * SCALE, 1), + }); + } + for (const node of doc.nodes) { + const pos = layout.nodePositions.get(node.id); + if (!pos) continue; + boxes.push({ + id: node.id, + label: node.label, + kind: "node", + x: (pos.x + doc.canvas.cell.card_w / 2 - cx) * SCALE, + z: (pos.y + doc.canvas.cell.card_h / 2 - cz) * SCALE, + w: doc.canvas.cell.card_w * SCALE, + d: doc.canvas.cell.card_h * SCALE, + }); + } + return { + boxes, + plateW: Math.max(layout.width * SCALE, 1), + plateD: Math.max(layout.height * SCALE, 1), + }; +} + +// IDEF0 explosion (FIPS PUB 183 §3.3.1.2-3 Fig.6): each deeper plane must +// sit UNDER the specific parent zone-box it details, not at world (0,0) — +// otherwise every plane stacks dead-center and the scene reads as a +// centered stack, never an explosion. Generalized (Stage 2) to fold over +// however many `docs` the drill history has accumulated: `docs[i]` is the +// document revealed by descending into `focusChain[i-1]` (docs[0] is +// always the root, with no corresponding focusChain entry). +export function computePlanesForDocs( + docs: readonly MapDocument[], + focusChain: readonly string[], +): PlaneSpec[] { + const planes: PlaneSpec[] = []; + let originX = 0; + let originZ = 0; + for (let i = 0; i < docs.length; i++) { + const shape = boxesForDoc(docs[i]!); + let label = "WHOLE SYSTEM"; + let focusId: string | null = null; + if (i > 0) { + focusId = focusChain[i - 1]!; + const parent = planes[i - 1]!; + const box = parent.boxes.find((b) => b.id === focusId); + originX = parent.originX + (box?.x ?? 0); + originZ = parent.originZ + (box?.z ?? 0); + label = box?.label ?? focusId; + } + planes.push({ + id: i === 0 ? "root" : focusChain.slice(0, i).join(">"), + focusId, + label, + y: -PLANE_GAP * i, + originX, + originZ, + ...shape, + }); + } + return planes; +} + +// ACCORDION depthWindow — of the full (arbitrary-depth) `planes` chain, +// only the last `depthWindow` entries render "expanded" (full plate + +// boxes); everything shallower collapses to a thin "sliver" stacked +// tightly just above the expanded block (SLIVER_GAP apart, not PLANE_GAP), +// so a long drill history compresses near the focus instead of receding +// forever. The expanded block always restarts its own PLANE_GAP spacing +// right below the sliver stack — a side-effect bonus is that the camera's +// static lookAt/target (IsoScene) stays roughly valid at any depth, since +// the expanded window's shape never changes, only what content is in it. +export function windowPlanes( + planes: readonly PlaneSpec[], + depthWindow: number, +): WindowedPlane[] { + const total = planes.length; + const windowSize = Math.max(1, Math.min(depthWindow, total)); + const windowStart = total - windowSize; + const sliverStackHeight = SLIVER_GAP * windowStart; + return planes.map((plane, i) => { + if (i < windowStart) { + return { + ...plane, + y: -SLIVER_GAP * (windowStart - i), + mode: "sliver" as const, + depthIndex: i, + }; + } + const j = i - windowStart; + return { + ...plane, + y: -(sliverStackHeight + PLANE_GAP * j), + mode: "expanded" as const, + depthIndex: i, + }; + }); +} + +// IDEF0 exploded pyramid: 4 lines from a parent zone's 4 top corners down +// to the 4 corners of the child level's floor frame (a truncated-pyramid / +// frustum wireframe), the signature "explosion" connector look. Each +// corner is its own segment (not one combined +// THREE.LineSegments buffer) so every segment can carry +// MeshLineMaterial's dashArray/dashRatio — FIPS Fig.6 draws correspondence +// lines dashed, not solid. `attenuate={false}` (set by callers) is +// load-bearing: MeshLine's default world-unit width attenuation is what +// produced huge misshapen kite polygons under this scene's +// OrthographicCamera in an earlier pass; pixel-space width (attenuate off) +// sidesteps that entirely. +export function rectCorners( + cx: number, + cz: number, + w: number, + d: number, + y: number, +): [THREE.Vector3, THREE.Vector3, THREE.Vector3, THREE.Vector3] { + const hw = w / 2; + const hd = d / 2; + return [ + new THREE.Vector3(cx - hw, y, cz - hd), + new THREE.Vector3(cx + hw, y, cz - hd), + new THREE.Vector3(cx + hw, y, cz + hd), + new THREE.Vector3(cx - hw, y, cz + hd), + ]; +} + +export interface CornerSegment { + id: string; + points: [THREE.Vector3, THREE.Vector3]; +} + +export interface PyramidGroup { + id: string; + /** Absolute depth index of the CHILD plane this connector leads to — lets + * IsoScene apply the child's enter/exit presence to this connector too. */ + childDepthIndex: number; + segments: CornerSegment[]; +} + +export function pyramidSegments( + idPrefix: string, + parent: { x: number; z: number; w: number; d: number; y: number }, + child: { x: number; z: number; plateW: number; plateD: number; y: number }, +): CornerSegment[] { + const parentCorners = rectCorners( + parent.x, + parent.z, + parent.w, + parent.d, + parent.y, + ); + const childCorners = rectCorners( + child.x, + child.z, + child.plateW, + child.plateD, + child.y, + ); + return parentCorners.map((corner, i) => ({ + id: `${idPrefix}-corner-${i}`, + points: [corner, childCorners[i]!] as [THREE.Vector3, THREE.Vector3], + })); +} + +// Generalized (Stage 2): one connector group per CONSECUTIVE pair of +// EXPANDED planes (a sliver ancestor carries no boxes, so there is no +// meaningful parent-box anchor to frustum from/to — collapsed history is +// conveyed by tight sliver-stack proximity alone, not by connector lines). +export function computeConnectorGroups( + planes: readonly WindowedPlane[], +): PyramidGroup[] { + const list: PyramidGroup[] = []; + for (let i = 1; i < planes.length; i++) { + const parent = planes[i - 1]!; + const child = planes[i]!; + if (parent.mode !== "expanded" || child.mode !== "expanded") continue; + if (child.focusId === null) continue; + const parentBox = parent.boxes.find((b) => b.id === child.focusId); + if (!parentBox) continue; + list.push({ + id: `${parent.id}->${child.id}`, + childDepthIndex: child.depthIndex, + segments: pyramidSegments( + `${parent.id}-${child.id}`, + { + x: parent.originX + parentBox.x, + z: parent.originZ + parentBox.z, + w: parentBox.w, + d: parentBox.d, + y: parent.y + ZONE_FRAME_H, + }, + { + x: child.originX, + z: child.originZ, + plateW: child.plateW, + plateD: child.plateD, + y: child.y, + }, + ), + }); + } + return list; +} + +// ICOM boundary arrows (FIPS Fig.6's signature feature, §3.3.2.7-8 +// Figs.14-15). For each zone being exploded, find the OWN document's edges +// that cross the zone's node-membership boundary (one endpoint inside, +// one outside), classify each via the real ADR-007 `classifyIcom` (never +// reinvented here), and draw one representative arrow per ICOM side +// (input/control/output/mechanism) — capped at one per side so a dense +// real graph doesn't turn into visual noise (FIPS tunneling escape +// hatch; v1 does not continue arrows into the child's own internal +// arrows either). `decomposition` (the `refines` relation) is excluded: +// it IS the exploded pyramid connector itself, not a boundary face — +// counting it again as an arrow would double-represent the same edge. +// Provenance (SPEC-004 honesty): a canonical forgeplan relation is real +// (solid line); anything else — e.g. this workspace's own `imports` +// code-dep edges — is classifyIcom's honest E-UNKNOWN fallback and +// renders dashed, same as the frustum's derived/inferred convention. +export interface IcomArrowSpec { + id: string; + side: IcomSide; + provenance: "real" | "derived"; +} + +export function classifyZoneBoundaryEdges( + doc: MapDocument, + zoneId: string, +): IcomArrowSpec[] { + const inZoneIds = new Set( + doc.nodes.filter((n) => n.zone === zoneId).map((n) => n.id), + ); + if (inZoneIds.size === 0) return []; + const seenSide = new Set(); + const specs: IcomArrowSpec[] = []; + for (const edge of doc.edges) { + const fromIn = inZoneIds.has(edge.from); + const toIn = inZoneIds.has(edge.to); + if (fromIn === toIn) continue; // not a boundary edge — both in or both out + const icom = classifyIcom(edge.relation); + if (icom === "decomposition") continue; // structural tree edge, not an ICOM face + const side = icomToSide(icom); + if (seenSide.has(side)) continue; // TODO(spike-icom): capped to 1 arrow/side, see comment above + seenSide.add(side); + specs.push({ + id: `${zoneId}-${side}`, + side, + provenance: isCanonicalRelation(edge.relation) ? "real" : "derived", + }); + } + return specs; +} + +export function faceAnchor( + cx: number, + cz: number, + w: number, + d: number, + side: IcomSide, +): { x: number; z: number; nx: number; nz: number } { + switch (side) { + case "left": + return { x: cx - w / 2, z: cz, nx: -1, nz: 0 }; + case "right": + return { x: cx + w / 2, z: cz, nx: 1, nz: 0 }; + case "top": + return { x: cx, z: cz - d / 2, nx: 0, nz: -1 }; + case "bottom": + default: + return { x: cx, z: cz + d / 2, nx: 0, nz: 1 }; + } +} + +export interface IcomArrowGeom { + id: string; + points: [THREE.Vector3, THREE.Vector3]; + provenance: "real" | "derived"; +} + +export function icomArrowSegment( + id: string, + anchor: { x: number; z: number; nx: number; nz: number }, + y: number, + provenance: "real" | "derived", +): IcomArrowGeom { + const inner = new THREE.Vector3(anchor.x, y, anchor.z); + const outer = new THREE.Vector3( + anchor.x + anchor.nx * ICOM_ARROW_LEN, + y, + anchor.z + anchor.nz * ICOM_ARROW_LEN, + ); + return { id, points: [inner, outer], provenance }; +} + +export interface IcomArrowPair { + id: string; + /** Same purpose as PyramidGroup.childDepthIndex (see above). */ + childDepthIndex: number; + parent: IcomArrowGeom; + child: IcomArrowGeom; +} + +// Generalized (Stage 2): loops every consecutive EXPANDED plane pair, +// reading boundary edges from `docs[i-1]` (the PARENT altitude's own +// document — same source Stage 1 used per hardcoded pair). +export function computeIcomArrows( + planes: readonly WindowedPlane[], + docs: readonly MapDocument[], +): IcomArrowPair[] { + const list: IcomArrowPair[] = []; + for (let i = 1; i < planes.length; i++) { + const parent = planes[i - 1]!; + const child = planes[i]!; + if (parent.mode !== "expanded" || child.mode !== "expanded") continue; + if (child.focusId === null) continue; + const parentDoc = docs[i - 1]; + if (!parentDoc) continue; + const parentBox = parent.boxes.find((b) => b.id === child.focusId); + if (!parentBox) continue; + const parentCx = parent.originX + parentBox.x; + const parentCz = parent.originZ + parentBox.z; + for (const spec of classifyZoneBoundaryEdges(parentDoc, child.focusId)) { + const parentAnchor = faceAnchor( + parentCx, + parentCz, + parentBox.w, + parentBox.d, + spec.side, + ); + const childAnchor = faceAnchor( + child.originX, + child.originZ, + child.plateW, + child.plateD, + spec.side, + ); + list.push({ + id: `${parent.id}-${spec.id}`, + childDepthIndex: child.depthIndex, + parent: icomArrowSegment( + `${parent.id}-${spec.id}-parent`, + parentAnchor, + parent.y + ZONE_FRAME_H, + spec.provenance, + ), + child: icomArrowSegment( + `${parent.id}-${spec.id}-child`, + childAnchor, + child.y, + spec.provenance, + ), + }); + } + } + return list; +} diff --git a/template/src/routes/iso-spike/lib/leader-line.ts b/template/src/routes/iso-spike/lib/leader-line.ts new file mode 100644 index 0000000..65bf397 --- /dev/null +++ b/template/src/routes/iso-spike/lib/leader-line.ts @@ -0,0 +1,39 @@ +// TODO(iso-promote): promote to widgets/iso-map + move shared drill logic to +// entities on graduation (see .claude/rules/10-comments-policy.md). +// +// Pure geometry for IsoLeaderLine: clamps a point onto the nearest edge of +// a DOMRect, so the leader line's card-side endpoint always lands ON the +// card's border rather than floating inside it. No Svelte, no DOM globals +// beyond the DOMRect/Point shapes themselves (same "pure helper" pattern +// as iso-projection.ts/iso-materials.ts). +export interface Point { + x: number; + y: number; +} + +export function nearestRectEdgePoint( + rect: Pick, + x: number, + y: number, +): Point { + const clampedX = Math.min(Math.max(x, rect.left), rect.right); + const clampedY = Math.min(Math.max(y, rect.top), rect.bottom); + const insideX = x > rect.left && x < rect.right; + const insideY = y > rect.top && y < rect.bottom; + + // (x,y) strictly inside the rect (the anchor sits behind its own card, + // e.g. a degenerate/very small viewport) — push out to the CLOSER edge + // instead of leaving the endpoint floating inside the card. + if (insideX && insideY) { + const candidates: Array<{ dist: number; point: Point }> = [ + { dist: x - rect.left, point: { x: rect.left, y } }, + { dist: rect.right - x, point: { x: rect.right, y } }, + { dist: y - rect.top, point: { x, y: rect.top } }, + { dist: rect.bottom - y, point: { x, y: rect.bottom } }, + ]; + candidates.sort((a, b) => a.dist - b.dist); + return candidates[0]!.point; + } + + return { x: clampedX, y: clampedY }; +} diff --git a/template/src/routes/iso-spike/lib/motion.ts b/template/src/routes/iso-spike/lib/motion.ts new file mode 100644 index 0000000..25d0ca4 --- /dev/null +++ b/template/src/routes/iso-spike/lib/motion.ts @@ -0,0 +1,16 @@ +// TODO(iso-promote): promote to widgets/iso-map + move shared drill logic to +// entities on graduation (see .claude/rules/10-comments-policy.md). +// +// Route-local copy of widgets/dependency-graph/lib/reduced-motion.ts's +// `motionDuration` helper. That file is not part of dependency-graph's +// public index.ts barrel, so importing it directly would reach into +// another widget's private lib/ (FSD encapsulation violation) — this route +// owns an identical, tiny copy instead until promotion (see header TODO) +// hoists a shared version both call sites can use. Safe on server: +// matchMedia is window-only, so it guards. +export function motionDuration(defaultMs: number): number { + if (typeof window === "undefined") return defaultMs; + return window.matchMedia("(prefers-reduced-motion: reduce)").matches + ? 0 + : defaultMs; +} diff --git a/template/src/routes/iso-spike/model/iso-view-state.svelte.ts b/template/src/routes/iso-spike/model/iso-view-state.svelte.ts new file mode 100644 index 0000000..4950beb --- /dev/null +++ b/template/src/routes/iso-spike/model/iso-view-state.svelte.ts @@ -0,0 +1,431 @@ +// TODO(iso-promote): promote to widgets/iso-map + move shared drill logic to +// entities on graduation (see .claude/rules/10-comments-policy.md). +// +// Stage 2 — dynamic re-layering + collapse/animate + accordion depthWindow. +// ALL new interaction/animation state for the iso-spike route lives here +// (SRP): the levelStack (RFC-031's own LevelFrame, reused verbatim — 3D +// pushLevel/popLevel/climbTo pass a dummy {x:0,y:0,k:1} transform since +// OrbitControls owns its own camera state independently of drill altitude), +// the depthWindow accordion setting, the on-demand emitted-layer cache +// (PRD-038 FR-002, the same fetch-once-per-zone gate ComposedMapView uses), +// and the enter/exit presence tweens driving a plane's grow-in / collapse- +// out animation. IsoScene.svelte CONSUMES this module (calls the exported +// functions, reads plane presence) — every other component (IsoPlane, +// IsoZoneFrame, IsoNodeBox, IsoFrustum, IsoIcomArrows, IsoSliverPlane, +// IsoDeeperMarker, IsoControls) stays a dumb, prop-only renderer +// (SRP/ISP/DIP) and never imports this module directly except IsoScene and +// +page.svelte (the two composition roots — 3D scene and page shell). +// +// Mirrors the module-level $state store shape used by +// widgets/composed-map/model/camera-bus.svelte.ts (no class, one shared +// instance per page) — same pattern the Stage-1 skeleton already used for +// hovered/focused below. + +import { Tween } from "svelte/motion"; +import { + validateMapDocument, + type MapDocument, + type MapNode, +} from "@/entities/map"; +import { isDrillable } from "@/entities/map/lib/derive-subdocument"; +import { + buildLevelDocuments, + isRootZoneDescend, +} from "@/widgets/composed-map/model/level-documents"; +import { + pushLevel, + popLevel, + climbTo as climbToFrame, + focusChain, + rootFrame, + type LevelFrame, +} from "@/widgets/composed-map/model/drill-state"; +import { + buildNodeConnections, + type NodeConnection, +} from "@/widgets/composed-map/model/node-tabs.svelte"; +import { + computePlanesForDocs, + windowPlanes, + NODE_BOX_H, +} from "../lib/iso-projection"; +import { motionDuration } from "../lib/motion"; + +export interface IsoViewTarget { + kind: "zone" | "node"; + id: string; +} + +// ---- pointer focus/hover --------------------------------------------- +// Stage-1 skeleton, now wired as the real "selected box" store, closing +// that stage's own TODO ("replace the local selectedId state in +// IsoScene.svelte with this focus store"). The former hover stub +// (setHovered/currentHovered) is retired in favor of the Stage-3 dwell +// system at the bottom of this file — a plane/sheet hover target isn't an +// IsoViewTarget (it has no zone/node id), so it needed its own shape +// rather than overloading this one. +let focused = $state(null); + +export function setFocused(target: IsoViewTarget | null): void { + focused = target; +} + +export function currentFocused(): IsoViewTarget | null { + return focused; +} + +// ---- drill-down level stack (generalized, arbitrary depth) ----------- +const DUMMY_TRANSFORM = { x: 0, y: 0, k: 1 }; +const ENTER_MS = 320; +const EXIT_MS = 260; + +let levelStack = $state([rootFrame(1)]); + +// ACCORDION — how many of the DEEPEST levels render fully expanded (plate +// + boxes); anything shallower collapses to a thin sliver (see +// lib/iso-projection.ts#windowPlanes). +let depthWindow = $state<1 | 2 | 3>(2); + +// PRD-038 FR-002 (E3 seam) — identical on-demand per-zone-layer cache/fetch +// gate as ComposedMapView.maybeFetchLayer: fetched at most once per zoneId, +// `null` cached on absent/invalid (client-derived deriveSubDocument fold +// applies, via buildLevelDocuments). This 3D scene has only one root, so +// "first descent from root" is whatever depth the user is currently +// drilling from — buildLevelDocuments already encodes that rule (it only +// ever consults the cache for focusChain index 0). +let layerCache = $state>(new Map()); +const pendingLayerFetches = new Set(); + +// Guards re-entrant descend/ascend/climbTo while an enter/exit tween is in +// flight, and tells IsoScene which tween value currently applies to the +// deepest rendered plane (and its connectors/arrows). +let animationKind = $state<"enter" | "exit" | null>(null); +const enterProgress = new Tween(0, { duration: 0 }); +const exitProgress = new Tween(1, { duration: 0 }); + +export function currentLevelStack(): LevelFrame[] { + return levelStack; +} + +export function currentFocusChain(): string[] { + return focusChain(levelStack); +} + +export function currentDepthWindow(): 1 | 2 | 3 { + return depthWindow; +} + +export function setDepthWindow(n: 1 | 2 | 3): void { + depthWindow = n; +} + +export function currentAnimationKind(): "enter" | "exit" | null { + return animationKind; +} + +export function currentEnterProgress(): number { + return enterProgress.current; +} + +export function currentExitProgress(): number { + return exitProgress.current; +} + +async function maybeFetchLayer(zoneId: string): Promise { + if (layerCache.has(zoneId) || pendingLayerFetches.has(zoneId)) return; + pendingLayerFetches.add(zoneId); + let resolved: MapDocument | null = null; + try { + const res = await fetch(`/api/map/layers/${encodeURIComponent(zoneId)}`); + const body = (await res.json()) as { ok: boolean; data?: unknown }; + if (body.ok && body.data && Object.keys(body.data as object).length > 0) { + const result = validateMapDocument(body.data); + if (result.ok) resolved = result.doc; + } + } catch { + resolved = null; + } finally { + pendingLayerFetches.delete(zoneId); + } + const next = new Map(layerCache); + next.set(zoneId, resolved); + layerCache = next; +} + +// Generalized docsByDepth — folds deriveSubDocument (or a cached emitted +// layer for the first descent) over the ENTIRE current focus chain, +// however deep it has grown (Stage-2 generalization of Stage-1's hardcoded +// two-level demo pair). +export function docsForRoot(rootDoc: MapDocument): MapDocument[] { + return buildLevelDocuments(rootDoc, focusChain(levelStack), layerCache); +} + +// Resolves a breadcrumb frame's focusId to its human label, at the +// altitude where it was a valid drill target — for LevelBreadcrumb's +// `labelFor` prop (reused as-is, RFC-031 Phase 4). +export function labelForFocus( + rootDoc: MapDocument, + focusId: string | null, +): string { + if (focusId === null) return "All"; + const docs = docsForRoot(rootDoc); + const planes = computePlanesForDocs(docs, focusChain(levelStack)); + return planes.find((p) => p.focusId === focusId)?.label ?? focusId; +} + +// CLICK GATE (Stage-2 FR-1) — descends one level ONLY when `focusId` +// resolves to a drillable zone/mega on the CURRENT deepest document; a +// leaf node click is a no-op here (the caller still calls setFocused for +// the select-only case — see IsoScene.svelte#handleBoxClick). Returns +// whether it actually descended, so the caller can decide whether to +// surface a "descended into" notice. +export function descend(rootDoc: MapDocument, focusId: string): boolean { + if (animationKind !== null) return false; + const docs = docsForRoot(rootDoc); + const activeDoc = docs[docs.length - 1]; + if (!activeDoc || !isDrillable(activeDoc, focusId)) return false; + + if (isRootZoneDescend(rootDoc, levelStack.length, focusId)) { + void maybeFetchLayer(focusId); + } + + levelStack = pushLevel(levelStack, focusId, DUMMY_TRANSFORM); + animationKind = "enter"; + enterProgress.set(0, { duration: 0 }); + void enterProgress.set(1, { duration: motionDuration(ENTER_MS) }).then(() => { + animationKind = null; + }); + return true; +} + +// Shared collapse-then-mutate shape for ascend()/climbTo(): the deepest +// plane visually shrinks to 0 FIRST; the level stack itself is only +// mutated once the tween settles (the real pop/truncate happens inside +// `apply()`, called from `.then()` — never before the animation finishes). +function collapseThenApply(apply: () => void): void { + animationKind = "exit"; + exitProgress.set(1, { duration: 0 }); + void exitProgress.set(0, { duration: motionDuration(EXIT_MS) }).then(() => { + apply(); + exitProgress.set(1, { duration: 0 }); + animationKind = null; + }); +} + +// FR-003 equivalent — ascend one level (the current deepest level +// collapses away, revealing its parent as the new deepest). +export function ascend(): void { + if (animationKind !== null || levelStack.length <= 1) return; + collapseThenApply(() => { + levelStack = popLevel(levelStack); + }); +} + +// Breadcrumb crumb click — climb directly to an ancestor level. +// TODO(iso-multi-collapse): only the single deepest plane animates its +// collapse even when this truncates more than one level at once (e.g. +// depth 4 -> depth 1 via a breadcrumb click); the intermediate levels +// vanish instantly. A fully-animated multi-level collapse is out of scope +// for this spike stage. +export function climbTo(index: number): void { + if (animationKind !== null || index < 0 || index >= levelStack.length - 1) { + return; + } + collapseThenApply(() => { + levelStack = climbToFrame(levelStack, index); + }); +} + +// ---- Stage 3: hover-dwell (nodes AND planes/sheets) ------------------- +// Unified dwell mechanism for BOTH hover targets this stage introduces — +// a node box's info card and a plane/sheet's info card + highlight are +// gated by the EXACT SAME timing rule (ZONE_DWELL_MS parity with +// ComposedMapView, restart-on-move), so one small state machine covers +// both instead of two near-duplicate ones (SRP: this IS "hover state", +// all of it, in one place, per this stage's own mandate). +export type IsoDwellTarget = + | { kind: "node"; id: string } + | { kind: "plane"; planeId: string; depthIndex: number }; + +function sameDwellTarget(a: IsoDwellTarget, b: IsoDwellTarget): boolean { + if (a.kind !== b.kind) return false; + return a.kind === "node" && b.kind === "node" + ? a.id === b.id + : a.kind === "plane" && b.kind === "plane" && a.planeId === b.planeId; +} + +const DWELL_MS = 350; + +// The raw target currently under the pointer (or keyboard focus) — armed +// on enter, cleared on leave. `dwellCard` only advances to it once it has +// survived DWELL_MS untouched (see armDwell) — mirrors ComposedMapView's +// hoveredZoneId/detailZoneId split exactly (parity requested by this +// stage), just generalized to two target KINDS instead of one. +let pointerDwellTarget = $state(null); +let dwellCard = $state(null); +let dwellTimer: ReturnType | null = null; + +function clearDwellTimer(): void { + if (dwellTimer !== null) { + clearTimeout(dwellTimer); + dwellTimer = null; + } +} + +export function currentDwellTarget(): IsoDwellTarget | null { + return dwellCard; +} + +// Pointer ENTERED (or keyboard focus landed on) a target — (re)arms the +// dwell timer. A target switch before DWELL_MS elapses restarts the clock +// from zero: every call clears the previous timer before starting a new +// one, so only a genuine DWELL_MS rest on ONE target ever fires it. +export function armDwell(target: IsoDwellTarget): void { + pointerDwellTarget = target; + clearDwellTimer(); + const captured = target; + dwellTimer = setTimeout(() => { + dwellTimer = null; + if (pointerDwellTarget && sameDwellTarget(pointerDwellTarget, captured)) { + dwellCard = captured; + } + }, DWELL_MS); +} + +// Pointer LEFT (or keyboard focus left) a target — cancels a still- +// pending arm and, if THIS target's card is the one currently shown, +// closes it. The sameDwellTarget guard stops a stale leave (fired after +// the pointer already moved on to a new target) from clearing that NEW +// target's card out from under it. +export function disarmDwell(target: IsoDwellTarget): void { + if (pointerDwellTarget && sameDwellTarget(pointerDwellTarget, target)) { + pointerDwellTarget = null; + clearDwellTimer(); + } + if (dwellCard && sameDwellTarget(dwellCard, target)) { + dwellCard = null; + } +} + +// IsoA11yProxy's keyboard path — a Tab landing on a specific hidden proxy +// button has already committed to that exact target (no "just passing +// through" ambiguity a mouse has), so it opens immediately, no DWELL_MS. +export function focusDwell(target: IsoDwellTarget): void { + clearDwellTimer(); + pointerDwellTarget = target; + dwellCard = target; +} + +export function blurDwell(target: IsoDwellTarget): void { + disarmDwell(target); +} + +export interface DwellNodeCardData { + kind: "node"; + node: MapNode; + connections: NodeConnection[]; + worldPos: [number, number, number]; +} + +export interface DwellLayerCardData { + kind: "plane"; + label: string; + zoneCount: number; + nodeCount: number; + descriptionRu?: string; + worldPos: [number, number, number]; +} + +function currentWindowedPlanes(rootDoc: MapDocument) { + const docs = docsForRoot(rootDoc); + const planes = computePlanesForDocs(docs, focusChain(levelStack)); + return { docs, planes: windowPlanes(planes, depthWindow) }; +} + +// Resolves the CURRENTLY dwelt target (if any) into the exact data its +// card needs — the single source both IsoNodeCard's and IsoLayerCard's +// caller (+page.svelte) reads from, so neither card component ever +// derives its own content (DIP: they stay dumb prop renderers). +export function resolveDwellCardData( + rootDoc: MapDocument, +): DwellNodeCardData | DwellLayerCardData | null { + const target = dwellCard; + if (!target) return null; + const { docs, planes } = currentWindowedPlanes(rootDoc); + + if (target.kind === "plane") { + const plane = planes.find( + (p) => p.mode === "expanded" && p.id === target.planeId, + ); + if (!plane) return null; + const doc = docs[plane.depthIndex]; + if (!doc) return null; + const parentDoc = plane.depthIndex > 0 ? docs[plane.depthIndex - 1] : null; + const descriptionRu = parentDoc?.zones.find( + (z) => z.id === plane.focusId, + )?.description_ru; + return { + kind: "plane", + label: plane.label, + zoneCount: doc.zones.length, + nodeCount: doc.nodes.length, + descriptionRu, + worldPos: [plane.originX, plane.y, plane.originZ], + }; + } + + for (const plane of planes) { + if (plane.mode !== "expanded") continue; + const box = plane.boxes.find( + (b) => b.kind === "node" && b.id === target.id, + ); + if (!box) continue; + const doc = docs[plane.depthIndex]; + const node = doc?.nodes.find((n) => n.id === target.id); + if (!doc || !node) return null; + return { + kind: "node", + node, + connections: buildNodeConnections(doc, target.id), + worldPos: [ + plane.originX + box.x, + plane.y + NODE_BOX_H / 2, + plane.originZ + box.z, + ], + }; + } + return null; +} + +export interface DwellTargetSummary { + target: IsoDwellTarget; + label: string; +} + +// IsoA11yProxy's data source — every CURRENTLY rendered dwellable target +// (one entry per visible expanded plane + one per node box on it), so the +// visually-hidden proxy button list always matches what a mouse could +// actually reach right now (never stale, never includes a collapsed +// sliver's contents). +export function currentDwellableTargets( + rootDoc: MapDocument, +): DwellTargetSummary[] { + const { planes } = currentWindowedPlanes(rootDoc); + const list: DwellTargetSummary[] = []; + for (const plane of planes) { + if (plane.mode !== "expanded") continue; + list.push({ + target: { + kind: "plane", + planeId: plane.id, + depthIndex: plane.depthIndex, + }, + label: `Layer: ${plane.label}`, + }); + for (const box of plane.boxes) { + if (box.kind !== "node") continue; + list.push({ target: { kind: "node", id: box.id }, label: box.label }); + } + } + return list; +} diff --git a/template/src/routes/iso-spike/ui/IsoA11yProxy.svelte b/template/src/routes/iso-spike/ui/IsoA11yProxy.svelte new file mode 100644 index 0000000..bff5d1f --- /dev/null +++ b/template/src/routes/iso-spike/ui/IsoA11yProxy.svelte @@ -0,0 +1,80 @@ + + +
+ {#each targets as entry (keyFor(entry))} + + {/each} +
+ + diff --git a/template/src/routes/iso-spike/ui/IsoControls.svelte b/template/src/routes/iso-spike/ui/IsoControls.svelte new file mode 100644 index 0000000..bcc5a8a --- /dev/null +++ b/template/src/routes/iso-spike/ui/IsoControls.svelte @@ -0,0 +1,66 @@ + + +
+
+ {#each DEPTH_OPTIONS as n (n)} + + {/each} +
+ +
+ + diff --git a/template/src/routes/iso-spike/ui/IsoDeeperMarker.svelte b/template/src/routes/iso-spike/ui/IsoDeeperMarker.svelte new file mode 100644 index 0000000..fe37369 --- /dev/null +++ b/template/src/routes/iso-spike/ui/IsoDeeperMarker.svelte @@ -0,0 +1,55 @@ + + +{#each corners as corner, i (i)} + + + + +{/each} diff --git a/template/src/routes/iso-spike/ui/IsoFrustum.svelte b/template/src/routes/iso-spike/ui/IsoFrustum.svelte new file mode 100644 index 0000000..d14273a --- /dev/null +++ b/template/src/routes/iso-spike/ui/IsoFrustum.svelte @@ -0,0 +1,44 @@ + + +{#each group.segments as seg (seg.id)} + + + + +{/each} diff --git a/template/src/routes/iso-spike/ui/IsoIcomArrows.svelte b/template/src/routes/iso-spike/ui/IsoIcomArrows.svelte new file mode 100644 index 0000000..a9c37ba --- /dev/null +++ b/template/src/routes/iso-spike/ui/IsoIcomArrows.svelte @@ -0,0 +1,61 @@ + + +{#each pairs as pair (pair.id)} + + 1 - p} /> + + + + 1 - p} /> + + +{/each} diff --git a/template/src/routes/iso-spike/ui/IsoLayerCard.svelte b/template/src/routes/iso-spike/ui/IsoLayerCard.svelte new file mode 100644 index 0000000..11342ba --- /dev/null +++ b/template/src/routes/iso-spike/ui/IsoLayerCard.svelte @@ -0,0 +1,100 @@ + + +
+

{label}

+
layer
+ {#if descriptionRu} +

{descriptionRu}

+ {/if} +
+ {zoneCount} {zoneCount === 1 ? 'zone' : 'zones'} + · + {nodeCount} {nodeCount === 1 ? 'node' : 'nodes'} +
+
+ + diff --git a/template/src/routes/iso-spike/ui/IsoLeaderLine.svelte b/template/src/routes/iso-spike/ui/IsoLeaderLine.svelte new file mode 100644 index 0000000..9412b85 --- /dev/null +++ b/template/src/routes/iso-spike/ui/IsoLeaderLine.svelte @@ -0,0 +1,132 @@ + + +{#if anchorWorldPos} + +
+ {#if line} + + {/if} + +{/if} + + diff --git a/template/src/routes/iso-spike/ui/IsoNodeBox.svelte b/template/src/routes/iso-spike/ui/IsoNodeBox.svelte new file mode 100644 index 0000000..96a6f7b --- /dev/null +++ b/template/src/routes/iso-spike/ui/IsoNodeBox.svelte @@ -0,0 +1,54 @@ + + + onClick?.(box)} + onpointerenter={(event) => { + event.stopPropagation(); + onPointerEnter?.(box); + }} + onpointerleave={(event) => { + event.stopPropagation(); + onPointerLeave?.(box); + }} +> + + + diff --git a/template/src/routes/iso-spike/ui/IsoNodeCard.svelte b/template/src/routes/iso-spike/ui/IsoNodeCard.svelte new file mode 100644 index 0000000..cdad4bb --- /dev/null +++ b/template/src/routes/iso-spike/ui/IsoNodeCard.svelte @@ -0,0 +1,141 @@ + + +
+

{node.label}

+
{node.kind}
+ {#if node.description_ru} +

{node.description_ru}

+ {/if} + {#if connections.length > 0} + +
    + {#each connections as conn (conn.dir + '|' + conn.relation + '|' + conn.label)} +
  • + + {conn.dir === 'out' ? '→' : '←'} + + {conn.label} + ({conn.relation}) +
  • + {/each} +
+ {/if} +
+ + diff --git a/template/src/routes/iso-spike/ui/IsoPlane.svelte b/template/src/routes/iso-spike/ui/IsoPlane.svelte new file mode 100644 index 0000000..c60e399 --- /dev/null +++ b/template/src/routes/iso-spike/ui/IsoPlane.svelte @@ -0,0 +1,127 @@ + + + + { + event.stopPropagation(); + onPlanePointerEnter?.(); + }} + onpointerleave={(event) => { + event.stopPropagation(); + onPlanePointerLeave?.(); + }} + > + + + {#if emphasized} + + {/if} + + + + + {#each plane.boxes as box (box.id)} + {#if box.kind === 'zone'} + + {:else} + + {/if} + {/each} + diff --git a/template/src/routes/iso-spike/ui/IsoSliverPlane.svelte b/template/src/routes/iso-spike/ui/IsoSliverPlane.svelte new file mode 100644 index 0000000..dbf2de6 --- /dev/null +++ b/template/src/routes/iso-spike/ui/IsoSliverPlane.svelte @@ -0,0 +1,29 @@ + + + + + + diff --git a/template/src/routes/iso-spike/ui/IsoZoneFrame.svelte b/template/src/routes/iso-spike/ui/IsoZoneFrame.svelte new file mode 100644 index 0000000..af55c4a --- /dev/null +++ b/template/src/routes/iso-spike/ui/IsoZoneFrame.svelte @@ -0,0 +1,55 @@ + + + onClick?.(box)} +> + + + + From 373c77f1da316c53fca22dea9ec9a5dc4de4b159 Mon Sep 17 00:00:00 2001 From: gogocat Date: Wed, 8 Jul 2026 01:02:04 +0300 Subject: [PATCH 03/17] =?UTF-8?q?wip(idef0):=20iso=20minimap=20=E2=80=94?= =?UTF-8?q?=20thin=20sheets,=20element=20hover,=20root-anchored=20depthWin?= =?UTF-8?q?dow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Thin translucent paper sheets (PLATE_THICKNESS/opacity down), hover highlights individual element not whole sheet, leader-line cards gated behind showInfoCards=false (minimap), windowPlanes root-anchored (first N levels expand downward), dashed-connector depthTest/renderOrder visibility fix, A11yProxy 3-kind union guard. svelte-check 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- template/src/routes/iso-spike/+page.svelte | 38 +++++++-- template/src/routes/iso-spike/IsoScene.svelte | 43 ++++++---- .../src/routes/iso-spike/lib/iso-materials.ts | 48 ++++++++--- .../routes/iso-spike/lib/iso-projection.ts | 40 +++++---- .../iso-spike/model/iso-view-state.svelte.ts | 85 +++++++++++++++---- .../routes/iso-spike/ui/IsoA11yProxy.svelte | 5 +- .../src/routes/iso-spike/ui/IsoFrustum.svelte | 4 +- .../routes/iso-spike/ui/IsoIcomArrows.svelte | 7 +- .../src/routes/iso-spike/ui/IsoNodeBox.svelte | 15 +++- .../src/routes/iso-spike/ui/IsoPlane.svelte | 51 ++++++----- .../routes/iso-spike/ui/IsoZoneFrame.svelte | 38 ++++++++- 11 files changed, 272 insertions(+), 102 deletions(-) diff --git a/template/src/routes/iso-spike/+page.svelte b/template/src/routes/iso-spike/+page.svelte index ca8a868..08e387f 100644 --- a/template/src/routes/iso-spike/+page.svelte +++ b/template/src/routes/iso-spike/+page.svelte @@ -44,16 +44,25 @@ // model/iso-view-state.svelte.ts singleton IsoScene consumes for the 3D // side (no prop drilling needed — see that module's header comment). // - // Stage 3: hover-dwell cards (IsoNodeCard/IsoLayerCard) + the a11y proxy - // are ALSO mounted here, as plain 2D siblings — this page is the ONLY - // place that resolves `resolveDwellCardData`/`currentDwellableTargets` - // into actual card content, so IsoNodeCard/IsoLayerCard/IsoA11yProxy - // stay dumb, prop-only renderers (DIP). is the one - // exception mounted INSIDE (it needs Threlte's camera/orbit + // Stage 3 (MINIMAP reframe): this view is a compact overview shown + // alongside the main 2D map — the leader-line + info-cards + // (IsoLeaderLine/IsoNodeCard/IsoLayerCard) are excessive for that job, + // so they are NOT mounted by default. `showInfoCards` (default false) + // gates them off while keeping the components on disk for a future + // re-enable (OCP) — nothing else in this stage's plumbing + // (resolveDwellCardData/currentDwellableTargets/dwellData/cardEl) had to + // change, since the primary hover feedback is now the per-element + // highlight in IsoZoneFrame.svelte/IsoNodeBox.svelte (Stage 3 redesign), + // not a card. stays mounted UNCONDITIONALLY — it's what + // makes that per-element highlight keyboard-reachable, independent of + // whether the (optional) info cards are shown. is the + // one exception mounted INSIDE (it needs Threlte's camera/orbit // context to project `anchorWorldPos` onto the screen) — see its own // header comment for why that's still just a sibling decoration, not a // change to IsoScene's own tree (OCP). + let { showInfoCards = false }: { showInfoCards?: boolean } = $props(); + const levelStack = $derived(currentLevelStack()); const depthWindow = $derived(currentDepthWindow()); @@ -109,6 +118,9 @@ {:else} + {#if showInfoCards} + + {/if}
iso-spike — 3D layered map (throwaway)
@@ -124,6 +136,20 @@ canAscend={levelStack.length > 1} onAscend={ascend} /> + {#if showInfoCards && dwellData} + {#if dwellData.kind === 'node'} + + {:else} + + {/if} + {/if} + {/if}
diff --git a/template/src/routes/iso-spike/IsoScene.svelte b/template/src/routes/iso-spike/IsoScene.svelte index efe5802..b5d3ee4 100644 --- a/template/src/routes/iso-spike/IsoScene.svelte +++ b/template/src/routes/iso-spike/IsoScene.svelte @@ -22,6 +22,7 @@ docsForRoot, currentFocusChain, currentDepthWindow, + currentDepthWindowAnimIndex, currentAnimationKind, currentEnterProgress, currentExitProgress, @@ -72,21 +73,30 @@ const windowedPlanes = $derived(windowPlanes(rawPlanes, currentDepthWindow())); const deepestDepthIndex = $derived(docsByDepth.length - 1); + // The absolute depthIndex currently being animated — either a + // depthWindow-triggered reveal/collapse (setDepthWindow), or, when none + // is in flight, the chain's own deepest index (a descend()/ascend() + // drill). Both animation sources share the SAME enter/exit tween pair + // (model/iso-view-state.svelte.ts), so exactly one of them is ever + // "the" animating index at a time. + const animIndex = $derived(currentDepthWindowAnimIndex() ?? deepestDepthIndex); + const connectorGroups = $derived(computeConnectorGroups(windowedPlanes)); const icomArrowPairs = $derived(computeIcomArrows(windowedPlanes, docsByDepth)); const settledArrowPairs = $derived( - icomArrowPairs.filter((p) => p.childDepthIndex !== deepestDepthIndex), + icomArrowPairs.filter((p) => p.childDepthIndex !== animIndex), ); const newestArrowPairs = $derived( - icomArrowPairs.filter((p) => p.childDepthIndex === deepestDepthIndex), + icomArrowPairs.filter((p) => p.childDepthIndex === animIndex), ); - // The single value driving the CURRENT deepest plane's grow-in - // (descend) / collapse-out (ascend) animation — every other, already- - // settled plane/connector/arrow renders at presence 1 (see - // model/iso-view-state.svelte.ts#collapseThenApply for why the level - // stack itself only mutates AFTER this settles on collapse). - const deepestPresence = $derived.by(() => { + // The single value driving the CURRENTLY animating plane's grow-in + // (descend / depthWindow reveal) / collapse-out (ascend / depthWindow + // shrink) animation — every other, already-settled plane/connector/arrow + // renders at presence 1 (see model/iso-view-state.svelte.ts# + // collapseThenApply for why the underlying state itself only mutates + // AFTER this settles on collapse). + const animPresence = $derived.by(() => { const kind = currentAnimationKind(); if (kind === 'enter') return currentEnterProgress(); if (kind === 'exit') return currentExitProgress(); @@ -94,7 +104,7 @@ }); function presenceFor(depthIndex: number): number { - return depthIndex === deepestDepthIndex ? deepestPresence : 1; + return depthIndex === animIndex ? animPresence : 1; } // "...deeper" affordance — the deepest doc still has drillable content @@ -127,10 +137,11 @@ const selectedId = $derived(currentFocused()?.id ?? null); - // Stage 3 — the current hover-dwell target (node OR plane), read once - // here so every rendered below can cheaply check "is it ME" - // without each plane re-deriving dwell state itself (ISP: IsoPlane just - // gets a plain `emphasized` boolean, it never imports the model). + // Stage 3 — the current hover-dwell target (zone, node, OR plane), read + // once here and threaded down as a plain typed VALUE so every rendered + // below can cheaply check "is it ME" per-box without each + // plane re-deriving dwell state itself (ISP: IsoPlane never calls the + // model, it only compares the value it was handed). const dwellTarget = $derived(currentDwellTarget()); // CLICK GATE (Stage-2 FR-1) — a drillable zone/mega descends one level; @@ -166,7 +177,7 @@ {colors} presence={presenceFor(plane.depthIndex)} interactive={plane.depthIndex === deepestDepthIndex} - emphasized={dwellTarget?.kind === 'plane' && dwellTarget.planeId === plane.id} + {dwellTarget} onBoxClick={handleBoxClick} onPlanePointerEnter={() => armDwell({ kind: 'plane', planeId: plane.id, depthIndex: plane.depthIndex })} @@ -174,6 +185,8 @@ disarmDwell({ kind: 'plane', planeId: plane.id, depthIndex: plane.depthIndex })} onNodePointerEnter={(box) => armDwell({ kind: 'node', id: box.id })} onNodePointerLeave={(box) => disarmDwell({ kind: 'node', id: box.id })} + onZonePointerEnter={(box) => armDwell({ kind: 'zone', id: box.id })} + onZonePointerLeave={(box) => disarmDwell({ kind: 'zone', id: box.id })} /> {:else} @@ -185,7 +198,7 @@ {/each} - + {#if hasDeeper && deepestPlane} (see // IsoZoneFrame.svelte) at a low-but-visible opacity, so zones read as thin // matte sheets tinted in their own outline color. -export const ZONE_FILL_OPACITY = 0.16; +// +// MINIMAP reframe: this view is a compact overview shown alongside the main +// 2D map, not a heavy interactive surface — sheets read as thin translucent +// PAPER, not dark plates, so opacity was lowered again (0.16 -> 0.07). +export const ZONE_FILL_OPACITY = 0.07; // polygonOffset (paired with depthWrite:false on the fill material) keeps // the coplanar fill + outline from z-fighting. export const ZONE_POLYGON_OFFSET_FACTOR = -1; export const ZONE_POLYGON_OFFSET_UNITS = -1; -// Per-level floor "plate" — thinner (paper-like) and faintly tinted. -export const PLATE_THICKNESS = 0.12; +// Per-level floor "plate" — thin translucent paper, faintly tinted (MINIMAP +// reframe: 0.12/0.28 read as heavy dark plates, dropped to 0.03/0.10). +export const PLATE_THICKNESS = 0.03; export const PLATE_Y_OFFSET = PLATE_THICKNESS / 2 + 0.02; -export const PLATE_OPACITY = 0.28; +export const PLATE_OPACITY = 0.1; + +// --- Stage 3 (redesigned): per-ELEMENT hover-dwell emphasis -------------- +// Highlights ONLY the individual zone/node under the cursor — the former +// whole-PLATE emphasis (PLATE_EMPHASIS_OPACITY_MULT, boosting the entire +// sheet's fill + gating a bright outline on the plate itself) is +// retired: on a minimap, brightening the whole sheet for one hovered box +// is disproportionate. `brightenForEmphasis` lightens the element's OWN +// color toward white; callers (IsoZoneFrame/IsoNodeBox) pair it with a +// modest fill boost for translucent elements. +export const ELEMENT_EMPHASIS_LERP = 0.55; +export const ELEMENT_EMPHASIS_FILL_MULT = 1.8; -// --- Stage 3: plane/sheet hover-dwell emphasis --------------------------- -// Boosts the plate's own fill opacity (on top of the existing depth -// falloff) and gates a bright, non-desaturated outline (see -// IsoPlane.svelte) while a plane is the current hover-dwell target. -export const PLATE_EMPHASIS_OPACITY_MULT = 1.9; +/** Brightens `color` toward white for the per-element hover-dwell + * highlight. Returns a NEW THREE.Color; never mutates the input. */ +export function brightenForEmphasis(color: THREE.Color): THREE.Color { + return color.clone().lerp(new THREE.Color(1, 1, 1), ELEMENT_EMPHASIS_LERP); +} // Monotonic depth falloff: shallower planes (index 0 = root) keep full // tint; deeper planes dim toward PLANE_FALLOFF_MIN so the exploded stack @@ -101,8 +117,15 @@ export function desaturateForDepth( // --- Fine dashed lines (frustum connectors + ICOM boundary arrows) ------- // Thinner, higher dash frequency, shorter dash segments than the original // fat-dash pass — a fine dotted look instead of chunky dashes. +// +// The now-thin translucent plates (PLATE_OPACITY/ZONE_FILL_OPACITY above) +// made these lines nearly invisible where they cross a sheet — fixed by +// pairing near-opaque CONNECTOR_LINE_OPACITY with `depthTest={false}` + +// a high `renderOrder` on the mesh (see IsoFrustum.svelte / +// IsoIcomArrows.svelte), so the dashed corner-to-corner lines always +// paint ON TOP of every plate/zone sheet regardless of draw order. export const CONNECTOR_LINE_WIDTH = 0.8; -export const CONNECTOR_LINE_OPACITY = 0.85; +export const CONNECTOR_LINE_OPACITY = 1; export const CONNECTOR_DASH_ARRAY = 0.06; export const CONNECTOR_DASH_RATIO = 0.65; @@ -110,3 +133,8 @@ export const ICOM_ARROW_WIDTH = 1.2; export const ICOM_ARROW_OPACITY = 0.95; export const ICOM_ARROW_DASH_ARRAY = 0.06; export const ICOM_ARROW_DASH_RATIO = 0.65; + +/** Shared renderOrder for dashed connector/arrow meshes — always higher + * than any plate's own `renderOrder={planeIndex}` (see IsoPlane.svelte), + * so these lines composite after (visually on top of) every sheet. */ +export const LINE_RENDER_ORDER = 1000; diff --git a/template/src/routes/iso-spike/lib/iso-projection.ts b/template/src/routes/iso-spike/lib/iso-projection.ts index cd2da2d..e7bf597 100644 --- a/template/src/routes/iso-spike/lib/iso-projection.ts +++ b/template/src/routes/iso-spike/lib/iso-projection.ts @@ -154,37 +154,35 @@ export function computePlanesForDocs( return planes; } -// ACCORDION depthWindow — of the full (arbitrary-depth) `planes` chain, -// only the last `depthWindow` entries render "expanded" (full plate + -// boxes); everything shallower collapses to a thin "sliver" stacked -// tightly just above the expanded block (SLIVER_GAP apart, not PLANE_GAP), -// so a long drill history compresses near the focus instead of receding -// forever. The expanded block always restarts its own PLANE_GAP spacing -// right below the sliver stack — a side-effect bonus is that the camera's -// static lookAt/target (IsoScene) stays roughly valid at any depth, since -// the expanded window's shape never changes, only what content is in it. +// ACCORDION depthWindow (MINIMAP reframe — ROOT-ANCHORED): of the full +// (arbitrary-depth) `planes` chain, the FIRST `depthWindow` entries — root +// downward — render "expanded" (full plate + boxes); everything DEEPER +// collapses to a thin "sliver" stacked tightly BELOW the expanded block +// (SLIVER_GAP apart, not PLANE_GAP). depthWindow=1 is root only (no +// explosion); 2 is root + the first child level exploded below; 3 adds one +// more level below that. The window is always contiguous from the top — +// root (index 0) is never hidden while a deeper index is shown — so +// increasing depthWindow reveals exactly the next-deeper level and +// decreasing it collapses exactly the deepest-currently-visible level +// first. Root's own position never moves (still y=0, same as the +// unwindowed `planes` passed in); only planes past the window are +// repositioned into the sliver stack. export function windowPlanes( planes: readonly PlaneSpec[], depthWindow: number, ): WindowedPlane[] { const total = planes.length; const windowSize = Math.max(1, Math.min(depthWindow, total)); - const windowStart = total - windowSize; - const sliverStackHeight = SLIVER_GAP * windowStart; + const expandedBottomY = -PLANE_GAP * (windowSize - 1); return planes.map((plane, i) => { - if (i < windowStart) { - return { - ...plane, - y: -SLIVER_GAP * (windowStart - i), - mode: "sliver" as const, - depthIndex: i, - }; + if (i < windowSize) { + return { ...plane, mode: "expanded" as const, depthIndex: i }; } - const j = i - windowStart; + const sliverDepth = i - windowSize + 1; return { ...plane, - y: -(sliverStackHeight + PLANE_GAP * j), - mode: "expanded" as const, + y: expandedBottomY - SLIVER_GAP * sliverDepth, + mode: "sliver" as const, depthIndex: i, }; }); diff --git a/template/src/routes/iso-spike/model/iso-view-state.svelte.ts b/template/src/routes/iso-spike/model/iso-view-state.svelte.ts index 4950beb..103c60f 100644 --- a/template/src/routes/iso-spike/model/iso-view-state.svelte.ts +++ b/template/src/routes/iso-spike/model/iso-view-state.svelte.ts @@ -81,11 +81,21 @@ const EXIT_MS = 260; let levelStack = $state([rootFrame(1)]); -// ACCORDION — how many of the DEEPEST levels render fully expanded (plate -// + boxes); anything shallower collapses to a thin sliver (see -// lib/iso-projection.ts#windowPlanes). +// ACCORDION (MINIMAP reframe — ROOT-ANCHORED) — how many levels, root +// downward, render fully expanded (plate + boxes); anything DEEPER +// collapses to a thin sliver (see lib/iso-projection.ts#windowPlanes). let depthWindow = $state<1 | 2 | 3>(2); +// The single absolute depthIndex (into the unwindowed docsByDepth/planes +// chain) whose presence is CURRENTLY being tweened by a depthWindow change +// (setDepthWindow below) — as opposed to a descend()/ascend() chain +// mutation, which always animates the chain's own deepest index instead. +// `null` means "no depthWindow-triggered animation in flight"; IsoScene +// falls back to the deepest chain index in that case (see +// IsoScene.svelte#presenceFor) so the two animation sources share one +// enter/exit tween pair without fighting over which plane it applies to. +let depthWindowAnimIndex = $state(null); + // PRD-038 FR-002 (E3 seam) — identical on-demand per-zone-layer cache/fetch // gate as ComposedMapView.maybeFetchLayer: fetched at most once per zoneId, // `null` cached on absent/invalid (client-derived deriveSubDocument fold @@ -115,8 +125,38 @@ export function currentDepthWindow(): 1 | 2 | 3 { return depthWindow; } +export function currentDepthWindowAnimIndex(): number | null { + return depthWindowAnimIndex; +} + +// Grows/shrinks the root-anchored expanded window by exactly one level per +// call (IsoControls only ever offers 1/2/3), animating whichever level just +// became visible/hidden with the SAME enter/exit tween descend()/ascend() +// use — reuses collapseThenApply's "mutate only after the tween settles" +// shape on shrink, mirrors descend()'s "mutate immediately, tween the grow- +// in" shape on grow (see collapseThenApply below). export function setDepthWindow(n: 1 | 2 | 3): void { - depthWindow = n; + if (animationKind !== null || n === depthWindow) return; + if (n > depthWindow) { + const revealedIndex = n - 1; + depthWindow = n; + depthWindowAnimIndex = revealedIndex; + animationKind = "enter"; + enterProgress.set(0, { duration: 0 }); + void enterProgress + .set(1, { duration: motionDuration(ENTER_MS) }) + .then(() => { + animationKind = null; + depthWindowAnimIndex = null; + }); + return; + } + const collapsingIndex = depthWindow - 1; + depthWindowAnimIndex = collapsingIndex; + collapseThenApply(() => { + depthWindow = n; + depthWindowAnimIndex = null; + }); } export function currentAnimationKind(): "enter" | "exit" | null { @@ -236,22 +276,27 @@ export function climbTo(index: number): void { }); } -// ---- Stage 3: hover-dwell (nodes AND planes/sheets) ------------------- -// Unified dwell mechanism for BOTH hover targets this stage introduces — -// a node box's info card and a plane/sheet's info card + highlight are +// ---- Stage 3 (redesigned): hover-dwell (zones, nodes, AND planes/sheets) +// Unified dwell mechanism for every hover target this stage introduces — // gated by the EXACT SAME timing rule (ZONE_DWELL_MS parity with -// ComposedMapView, restart-on-move), so one small state machine covers -// both instead of two near-duplicate ones (SRP: this IS "hover state", -// all of it, in one place, per this stage's own mandate). +// ComposedMapView, restart-on-move), so one small state machine covers all +// of them instead of several near-duplicate ones (SRP: this IS "hover +// state", all of it, in one place, per this stage's own mandate). +// MINIMAP reframe: `kind: 'zone'` was added so a hovered zone highlights +// itself (IsoZoneFrame.svelte), matching the existing per-node highlight — +// the retired whole-plane emphasis used to be the only feedback a zone +// hover produced. `kind: 'plane'` is kept (not removed) purely to back the +// optional info-card path (+page.svelte, gated behind `showInfoCards`). export type IsoDwellTarget = | { kind: "node"; id: string } + | { kind: "zone"; id: string } | { kind: "plane"; planeId: string; depthIndex: number }; function sameDwellTarget(a: IsoDwellTarget, b: IsoDwellTarget): boolean { - if (a.kind !== b.kind) return false; - return a.kind === "node" && b.kind === "node" - ? a.id === b.id - : a.kind === "plane" && b.kind === "plane" && a.planeId === b.planeId; + if (a.kind === "plane" || b.kind === "plane") { + return a.kind === "plane" && b.kind === "plane" && a.planeId === b.planeId; + } + return a.kind === b.kind && a.id === b.id; } const DWELL_MS = 350; @@ -374,6 +419,10 @@ export function resolveDwellCardData( }; } + // A hovered ZONE highlights itself (IsoZoneFrame.svelte) but has no info + // card of its own (only node/plane do) — no data to resolve here. + if (target.kind === "zone") return null; + for (const plane of planes) { if (plane.mode !== "expanded") continue; const box = plane.boxes.find( @@ -403,8 +452,8 @@ export interface DwellTargetSummary { } // IsoA11yProxy's data source — every CURRENTLY rendered dwellable target -// (one entry per visible expanded plane + one per node box on it), so the -// visually-hidden proxy button list always matches what a mouse could +// (one entry per visible expanded plane + one per zone/node box on it), so +// the visually-hidden proxy button list always matches what a mouse could // actually reach right now (never stale, never includes a collapsed // sliver's contents). export function currentDwellableTargets( @@ -423,8 +472,8 @@ export function currentDwellableTargets( label: `Layer: ${plane.label}`, }); for (const box of plane.boxes) { - if (box.kind !== "node") continue; - list.push({ target: { kind: "node", id: box.id }, label: box.label }); + const kind = box.kind === "node" ? "node" : "zone"; + list.push({ target: { kind, id: box.id }, label: box.label }); } } return list; diff --git a/template/src/routes/iso-spike/ui/IsoA11yProxy.svelte b/template/src/routes/iso-spike/ui/IsoA11yProxy.svelte index bff5d1f..6f8909b 100644 --- a/template/src/routes/iso-spike/ui/IsoA11yProxy.svelte +++ b/template/src/routes/iso-spike/ui/IsoA11yProxy.svelte @@ -31,9 +31,8 @@ } = $props(); function keyFor(entry: DwellTargetSummary): string { - return entry.target.kind === 'node' - ? `node:${entry.target.id}` - : `plane:${entry.target.planeId}`; + const t = entry.target; + return t.kind === 'plane' ? `plane:${t.planeId}` : `${t.kind}:${t.id}`; } diff --git a/template/src/routes/iso-spike/ui/IsoFrustum.svelte b/template/src/routes/iso-spike/ui/IsoFrustum.svelte index d14273a..257940f 100644 --- a/template/src/routes/iso-spike/ui/IsoFrustum.svelte +++ b/template/src/routes/iso-spike/ui/IsoFrustum.svelte @@ -19,6 +19,7 @@ CONNECTOR_LINE_OPACITY, CONNECTOR_DASH_ARRAY, CONNECTOR_DASH_RATIO, + LINE_RENDER_ORDER, } from '../lib/iso-materials'; let { @@ -29,13 +30,14 @@ {#each group.segments as seg (seg.id)} - + {#each pairs as pair (pair.id)} - + 1 - p} /> - + 1 - p} /> void; onPointerEnter?: (box: BoxSpec) => void; onPointerLeave?: (box: BoxSpec) => void; } = $props(); + + const boxColor = $derived(emphasized ? brightenForEmphasis(color) : color); onClick?.(box)} - onpointerenter={(event) => { + onpointerenter={(event: PointerEvent) => { event.stopPropagation(); onPointerEnter?.(box); }} - onpointerleave={(event) => { + onpointerleave={(event: PointerEvent) => { event.stopPropagation(); onPointerLeave?.(box); }} > - + diff --git a/template/src/routes/iso-spike/ui/IsoPlane.svelte b/template/src/routes/iso-spike/ui/IsoPlane.svelte index c60e399..8123a50 100644 --- a/template/src/routes/iso-spike/ui/IsoPlane.svelte +++ b/template/src/routes/iso-spike/ui/IsoPlane.svelte @@ -18,25 +18,30 @@ // the current deepest plane may be drilled into further (Stage-2 CLICK // GATE); ancestor/context planes in the depthWindow render but are inert. // - // Stage 3: `emphasized` (whether THIS plane is the current hover-dwell - // target) boosts the plate's own fill opacity and gates a bright - // outline — independent of `interactive`/`selectedId`, since hovering a - // sheet for its info card is a read-only affordance available on every - // rendered expanded plane, not just the deepest/drillable one. + // Stage 3 (redesigned): the plate itself no longer emphasizes as a + // WHOLE on hover — MINIMAP reframe retired the whole-plate boost + // (PLATE_EMPHASIS_OPACITY_MULT + bright plate ) in favor of + // highlighting only the individual zone/node under the cursor (see + // IsoZoneFrame.svelte / IsoNodeBox.svelte). `dwellTarget` is threaded + // through as a plain typed VALUE (type-only import — same pattern + // IsoA11yProxy.svelte already uses) purely so each box below can check + // "is it ME"; this component still never CALLS the model (no armDwell/ + // disarmDwell here — ISP holds). // onPlanePointerEnter/Leave fire from the plate mesh itself (stopping // propagation so a lower-stacked plane along the same ray doesn't also - // register as hovered); onNodePointerEnter/Leave are forwarded straight - // through to each IsoNodeBox (ISP — this component doesn't know what a + // register as hovered) — kept for the optional plane-level info card + // (+page.svelte, gated behind `showInfoCards`); onNodePointerEnter/Leave + // and onZonePointerEnter/Leave are forwarded straight through to each + // IsoNodeBox/IsoZoneFrame (ISP — this component doesn't know what a // "dwell" is, it just relays box-level pointer events up). import { T } from '@threlte/core'; - import { Edges } from '@threlte/extras'; import type { BoxSpec, PlaneSpec } from '../lib/iso-projection'; + import type { IsoDwellTarget } from '../model/iso-view-state.svelte'; import { type IsoColorTokens, PLATE_THICKNESS, PLATE_Y_OFFSET, PLATE_OPACITY, - PLATE_EMPHASIS_OPACITY_MULT, ZONE_FILL_OPACITY, planeFalloff, desaturateForDepth, @@ -51,12 +56,14 @@ colors, presence = 1, interactive = true, - emphasized = false, + dwellTarget = null, onBoxClick, onPlanePointerEnter, onPlanePointerLeave, onNodePointerEnter, onNodePointerLeave, + onZonePointerEnter, + onZonePointerLeave, }: { plane: PlaneSpec; planeIndex: number; @@ -64,40 +71,42 @@ colors: IsoColorTokens; presence?: number; interactive?: boolean; - emphasized?: boolean; + dwellTarget?: IsoDwellTarget | null; onBoxClick?: (box: BoxSpec) => void; onPlanePointerEnter?: () => void; onPlanePointerLeave?: () => void; onNodePointerEnter?: (box: BoxSpec) => void; onNodePointerLeave?: (box: BoxSpec) => void; + onZonePointerEnter?: (box: BoxSpec) => void; + onZonePointerLeave?: (box: BoxSpec) => void; } = $props(); const falloff = $derived(planeFalloff(planeIndex)); const plateColor = $derived(desaturateForDepth(colors.plate, planeIndex)); - const plateOpacity = $derived( - PLATE_OPACITY * falloff * (emphasized ? PLATE_EMPHASIS_OPACITY_MULT : 1), - ); + const plateOpacity = $derived(PLATE_OPACITY * falloff); const clickHandler = $derived(interactive ? onBoxClick : undefined); + + function isEmphasized(box: BoxSpec): boolean { + if (!dwellTarget || dwellTarget.kind === 'plane') return false; + return dwellTarget.kind === box.kind && dwellTarget.id === box.id; + } { + onpointerenter={(event: PointerEvent) => { event.stopPropagation(); onPlanePointerEnter?.(); }} - onpointerleave={(event) => { + onpointerleave={(event: PointerEvent) => { event.stopPropagation(); onPlanePointerLeave?.(); }} > - {#if emphasized} - - {/if} Date: Wed, 8 Jul 2026 02:11:01 +0300 Subject: [PATCH 08/17] refactor(idef0): graduate iso view routes/iso-spike -> widgets/iso-map Move IsoScene + ui/ + lib/ + model/ into a widgets/iso-map/ slice (internal structure preserved, relative imports intact). /iso-spike route now imports from the widget (route->widget, FSD-legal). Prep for mounting the 3D iso in the Map view's minimap corner. svelte-check 0. Co-Authored-By: Claude Opus 4.8 (1M context) --- template/src/routes/iso-spike/+page.svelte | 14 +++++++------- .../iso-spike => widgets/iso-map}/IsoScene.svelte | 0 .../iso-map}/lib/iso-materials.ts | 0 .../iso-map}/lib/iso-projection.ts | 0 .../iso-map}/lib/leader-line.ts | 0 .../iso-spike => widgets/iso-map}/lib/motion.ts | 0 .../iso-map}/model/iso-view-state.svelte.ts | 0 .../iso-map}/ui/IsoA11yProxy.svelte | 0 .../iso-map}/ui/IsoControls.svelte | 0 .../iso-map}/ui/IsoDeeperMarker.svelte | 0 .../iso-map}/ui/IsoFrustum.svelte | 0 .../iso-map}/ui/IsoIcomArrows.svelte | 0 .../iso-map}/ui/IsoLayerCard.svelte | 0 .../iso-map}/ui/IsoLeaderLine.svelte | 0 .../iso-map}/ui/IsoNodeBox.svelte | 0 .../iso-map}/ui/IsoNodeCard.svelte | 0 .../iso-map}/ui/IsoPlane.svelte | 0 .../iso-map}/ui/IsoSliverPlane.svelte | 0 .../iso-map}/ui/IsoZoneFrame.svelte | 0 19 files changed, 7 insertions(+), 7 deletions(-) rename template/src/{routes/iso-spike => widgets/iso-map}/IsoScene.svelte (100%) rename template/src/{routes/iso-spike => widgets/iso-map}/lib/iso-materials.ts (100%) rename template/src/{routes/iso-spike => widgets/iso-map}/lib/iso-projection.ts (100%) rename template/src/{routes/iso-spike => widgets/iso-map}/lib/leader-line.ts (100%) rename template/src/{routes/iso-spike => widgets/iso-map}/lib/motion.ts (100%) rename template/src/{routes/iso-spike => widgets/iso-map}/model/iso-view-state.svelte.ts (100%) rename template/src/{routes/iso-spike => widgets/iso-map}/ui/IsoA11yProxy.svelte (100%) rename template/src/{routes/iso-spike => widgets/iso-map}/ui/IsoControls.svelte (100%) rename template/src/{routes/iso-spike => widgets/iso-map}/ui/IsoDeeperMarker.svelte (100%) rename template/src/{routes/iso-spike => widgets/iso-map}/ui/IsoFrustum.svelte (100%) rename template/src/{routes/iso-spike => widgets/iso-map}/ui/IsoIcomArrows.svelte (100%) rename template/src/{routes/iso-spike => widgets/iso-map}/ui/IsoLayerCard.svelte (100%) rename template/src/{routes/iso-spike => widgets/iso-map}/ui/IsoLeaderLine.svelte (100%) rename template/src/{routes/iso-spike => widgets/iso-map}/ui/IsoNodeBox.svelte (100%) rename template/src/{routes/iso-spike => widgets/iso-map}/ui/IsoNodeCard.svelte (100%) rename template/src/{routes/iso-spike => widgets/iso-map}/ui/IsoPlane.svelte (100%) rename template/src/{routes/iso-spike => widgets/iso-map}/ui/IsoSliverPlane.svelte (100%) rename template/src/{routes/iso-spike => widgets/iso-map}/ui/IsoZoneFrame.svelte (100%) diff --git a/template/src/routes/iso-spike/+page.svelte b/template/src/routes/iso-spike/+page.svelte index e73c0f9..34fe482 100644 --- a/template/src/routes/iso-spike/+page.svelte +++ b/template/src/routes/iso-spike/+page.svelte @@ -1,11 +1,11 @@ -
- {#if rootBranch.kind === 'loading'} -

Loading map…

- {:else if rootBranch.kind === 'empty'} -

- No .forgeplan/map/map.json found — run the forgeplan-map-pack pipeline first. -

- {:else if rootBranch.kind === 'error'} -

Failed to load map: {rootBranch.message}

- {:else} - - - {#if showInfoCards} - - {/if} - -
-
iso-spike — 3D layered map (throwaway)
-
- drag to orbit · scroll to zoom · click a drillable box to explode its layers, click it - again to collapse -
- {#if lastDescend} -
descended into: {lastDescend.id} — {lastDescend.label}
- {/if} -
- - setDepthWindow(rootBranch.doc, n)} - canAscend={levelStack.length > 1} - onAscend={ascend} - /> - {#if showInfoCards && dwellData} - {#if dwellData.kind === 'node'} - - {:else} - - {/if} - {/if} - - {/if} -
- - + diff --git a/template/src/widgets/dependency-graph/ui/DependencyGraph.svelte b/template/src/widgets/dependency-graph/ui/DependencyGraph.svelte index 6c10706..1c5822d 100644 --- a/template/src/widgets/dependency-graph/ui/DependencyGraph.svelte +++ b/template/src/widgets/dependency-graph/ui/DependencyGraph.svelte @@ -13,6 +13,7 @@ import Idef0View from './Idef0View.svelte'; import ComposedMapView from '../../composed-map/ui/ComposedMapView.svelte'; import Minimap from './Minimap.svelte'; + import IsoMapCorner from './IsoMapCorner.svelte'; let { view = 'force', @@ -215,13 +216,21 @@ /> {/if} - + {#if view === 'map'} + + + {:else} + + {/if} diff --git a/template/src/widgets/iso-map/index.ts b/template/src/widgets/iso-map/index.ts new file mode 100644 index 0000000..5e2993e --- /dev/null +++ b/template/src/widgets/iso-map/index.ts @@ -0,0 +1 @@ +export { default as IsoMinimap } from "./ui/IsoMinimap.svelte"; diff --git a/template/src/widgets/iso-map/ui/IsoMinimap.svelte b/template/src/widgets/iso-map/ui/IsoMinimap.svelte new file mode 100644 index 0000000..fbecb02 --- /dev/null +++ b/template/src/widgets/iso-map/ui/IsoMinimap.svelte @@ -0,0 +1,145 @@ + + +
+ {#if rootBranch.kind === "loading"} +

Loading map…

+ {:else if rootBranch.kind === "empty"} +

No map yet

+ {:else if rootBranch.kind === "error"} +

Map error: {rootBranch.message}

+ {:else} + + + + {#if fullscreen} + + {/if} + setDepthWindow(rootBranch.doc, n)} + canAscend={levelStack.length > 1} + onAscend={ascend} + /> + + {/if} +
+ + From 5b1c3a4d9e2ce2f5fc2c7da95e645d25cc990b3c Mon Sep 17 00:00:00 2001 From: gogocat Date: Wed, 8 Jul 2026 02:41:22 +0300 Subject: [PATCH 10/17] =?UTF-8?q?perf(idef0):=20iso=20bundle=20surgery=20?= =?UTF-8?q?=E2=80=94=206.0M=20->=203.29M=20dist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kill unused @threlte/extras draco/basis loaders (~1.5M) via a vite alias stub (vite/stubs/three-loaders.ts); ssr=false + browser-guarded dynamic import for /iso-spike and IsoMapCorner so three is fully OUT of the SSR server bundle (0 three markers in dist/index.js). Remaining over-cap is three+threlte itself (808K, lazy client chunk). Still 0.29M over the 3M cap — cap decision pending. Co-Authored-By: Claude Opus 4.8 (1M context) --- template/src/routes/iso-spike/+page.svelte | 17 +++++++++-- template/src/routes/iso-spike/+page.ts | 7 +++++ .../dependency-graph/ui/IsoMapCorner.svelte | 29 ++++++++++++++----- template/vite.config.ts | 18 ++++++++++++ template/vite/stubs/three-loaders.ts | 20 +++++++++++++ 5 files changed, 82 insertions(+), 9 deletions(-) create mode 100644 template/src/routes/iso-spike/+page.ts create mode 100644 template/vite/stubs/three-loaders.ts diff --git a/template/src/routes/iso-spike/+page.svelte b/template/src/routes/iso-spike/+page.svelte index ee0cf2c..0b8939a 100644 --- a/template/src/routes/iso-spike/+page.svelte +++ b/template/src/routes/iso-spike/+page.svelte @@ -8,7 +8,20 @@ // the graduated widgets/iso-map/ui/IsoMinimap.svelte (the same component // DependencyGraph's corner mount uses in its default, container-sized // mode) — this route just asks for the `fullscreen` layout. - import { IsoMinimap } from "@/widgets/iso-map"; + // + // `browser`-guarded dynamic import (not a static import): three/@threlte + // are heavy, and this file's compiled output is still reachable from the + // adapter-node server manifest (every route node is referenced there + // regardless of its own ssr flag — see +page.ts). Guarding with `browser` + // lets Vite's SSR build tree-shake the import out of the server bundle + // entirely; the client build (where this code actually runs) is unaffected. + import { browser } from '$app/environment'; + + const isoMapModule = browser ? import('@/widgets/iso-map') : null; - +{#if isoMapModule} + {#await isoMapModule then mod} + + {/await} +{/if} diff --git a/template/src/routes/iso-spike/+page.ts b/template/src/routes/iso-spike/+page.ts new file mode 100644 index 0000000..797aaeb --- /dev/null +++ b/template/src/routes/iso-spike/+page.ts @@ -0,0 +1,7 @@ +// Throwaway dev spike (see +page.svelte header) — never server-rendered or +// prerendered. Root +layout.ts already sets ssr=false/prerender=false for +// the whole app; this local declaration keeps the route explicit on its own +// (three/@threlte are heavy — the browser-guarded import in +page.svelte is +// what actually keeps them out of the SSR/server bundle). +export const ssr = false; +export const prerender = false; diff --git a/template/src/widgets/dependency-graph/ui/IsoMapCorner.svelte b/template/src/widgets/dependency-graph/ui/IsoMapCorner.svelte index a334951..288f128 100644 --- a/template/src/widgets/dependency-graph/ui/IsoMapCorner.svelte +++ b/template/src/widgets/dependency-graph/ui/IsoMapCorner.svelte @@ -8,17 +8,32 @@ // the Map view and this corner actually mounts. Threlte's Canvas defaults // to `renderMode: 'on-demand'` (no prop override needed) — the corner // canvas only re-renders on scene invalidation, not every frame. - const isoMapModule = import("@/widgets/iso-map"); + // + // `browser`-guarded: this component itself is reachable from the + // adapter-node server manifest (DependencyGraph is part of the main page, + // which is always in the server-side route graph regardless of its own + // ssr flag). Guarding with `browser` lets Vite's SSR build tree-shake the + // dynamic import out of the server bundle entirely — otherwise the + // single-file esbuild bundling step (no code-splitting) inlines it and + // three/@threlte blow past the dist/ size cap. The client build (where + // this corner actually mounts) is unaffected. + import { browser } from '$app/environment'; + + const isoMapModule = browser ? import('@/widgets/iso-map') : null;
- {#await isoMapModule} -
loading 3D…
- {:then mod} - - {:catch} + {#if isoMapModule} + {#await isoMapModule} +
loading 3D…
+ {:then mod} + + {:catch} +
3D minimap unavailable
+ {/await} + {:else}
3D minimap unavailable
- {/await} + {/if}
From 56ee61fa7b9fae95e2f159496d0736eed24d424d Mon Sep 17 00:00:00 2001 From: gogocat Date: Wed, 8 Jul 2026 04:54:38 +0300 Subject: [PATCH 14/17] =?UTF-8?q?docs(idef0):=20resolve=20TODO(iso-adr)=20?= =?UTF-8?q?=E2=80=94=20cap=20amendment=20recorded=20in=20ADR-011?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix the build.mjs cap comment: the amended cap is PRD-030 NFR-001 / SC-4 / rule 21 (not NFR-005 — that's the flag-lifecycle policy), and the deliberate 3->3.5 MiB bump is now recorded in ADR-011. Resolves the TODO(iso-adr) placeholder. Co-Authored-By: Claude Opus 4.8 (1M context) --- scripts/build.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/build.mjs b/scripts/build.mjs index 995d6cb..39abd97 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -27,8 +27,8 @@ const PKG_FILE = join(ROOT, "package.json"); // + future small additions, while alarming on accidental bloat. // Raised 3M -> 3.5M to accommodate the lazy-loaded 3D Map minimap // (three.js + Threlte, ~808K client chunk, loaded only when the Map view -// opens). TODO(iso-adr): record the deliberate bump in an ADR amending -// PRD-030 NFR-005 before this ships. +// opens). Deliberate amendment of PRD-030 NFR-001 / SC-4 / rule 21, +// recorded in ADR-011 (ship three.js+Threlte lazy chunk; cap 3->3.5 MiB). const IMAGE_DIST_MAX_BYTES = 3.5 * 1024 * 1024; // Lifecycle policy (PRD-030 NFR-005, RFC-026 Phase 1): a flag's expiresIn From 92b26b6be221e28d46fc244aadd93d49e1fa213c Mon Sep 17 00:00:00 2001 From: gogocat Date: Wed, 8 Jul 2026 12:51:00 +0300 Subject: [PATCH 15/17] =?UTF-8?q?fix(idef0):=20close=20EVID-100=20review?= =?UTF-8?q?=20findings=20=E2=80=94=20sync-retry,=20WebGL=20boundary,=20doc?= =?UTF-8?q?/RFC=20drift?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HIGH: sync-drop race — 3D now records a pending focus-chain target when an update lands mid-animation and re-applies it on animation settle, so the 3D always converges to the 2D (RFC-036 NFR-004 / INV-E no-drift). - MED: IsoMapCorner wraps the 3D mount in so a runtime WebGL/init failure shows an honest fallback (FR-007), not a broken corner. - docs: dropped the stale 'throwaway spike' comment, deduped the TODO(iso-promote) copies, removed the phantom TODO(iso-draco-basis), aligned shared-drill-bus public API names to the RFC-036 contract. svelte-check 0, vitest 782/782, npm run build passes (dist 3.4M both images). TODO: shared-drill-bus unit tests still owed (EVID-100 finding #2). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../model/shared-drill-bus.svelte.ts | 25 ++++++++- .../composed-map/ui/ComposedMapView.svelte | 8 +-- .../dependency-graph/ui/IsoMapCorner.svelte | 16 +++++- template/src/widgets/iso-map/IsoScene.svelte | 7 --- template/src/widgets/iso-map/index.ts | 6 ++ .../src/widgets/iso-map/lib/iso-materials.ts | 3 - .../src/widgets/iso-map/lib/iso-projection.ts | 3 - .../src/widgets/iso-map/lib/leader-line.ts | 3 - template/src/widgets/iso-map/lib/motion.ts | 8 +-- .../iso-map/model/iso-view-state.svelte.ts | 53 +++++++++++++++--- .../widgets/iso-map/ui/IsoA11yProxy.svelte | 3 - .../src/widgets/iso-map/ui/IsoControls.svelte | 3 - .../widgets/iso-map/ui/IsoDeeperMarker.svelte | 3 - .../src/widgets/iso-map/ui/IsoFrustum.svelte | 3 - .../widgets/iso-map/ui/IsoIcomArrows.svelte | 3 - .../widgets/iso-map/ui/IsoLayerCard.svelte | 3 - .../widgets/iso-map/ui/IsoLeaderLine.svelte | 3 - .../src/widgets/iso-map/ui/IsoMinimap.svelte | 21 +++---- .../src/widgets/iso-map/ui/IsoNodeBox.svelte | 3 - .../src/widgets/iso-map/ui/IsoNodeCard.svelte | 3 - .../src/widgets/iso-map/ui/IsoPlane.svelte | 3 - .../widgets/iso-map/ui/IsoSliverPlane.svelte | 3 - .../widgets/iso-map/ui/IsoZoneFrame.svelte | 3 - template/vite.config.ts | 56 +++++++++++-------- 24 files changed, 141 insertions(+), 104 deletions(-) diff --git a/template/src/widgets/composed-map/model/shared-drill-bus.svelte.ts b/template/src/widgets/composed-map/model/shared-drill-bus.svelte.ts index b331332..e85d2a4 100644 --- a/template/src/widgets/composed-map/model/shared-drill-bus.svelte.ts +++ b/template/src/widgets/composed-map/model/shared-drill-bus.svelte.ts @@ -15,6 +15,29 @@ // Plain data only — this file MUST NOT import three/@threlte (or anything // that does), so importing it from the 2D/SSR path never pulls the 3D // dependency graph in. +// +// Naming note (EVID-100 Finding #7): RFC-036 documents this bus's contract +// as `focusChain` (the reactive chain) + `focusTo(chain)` (reconcile to an +// explicit chain). `focusTo` below is exactly what this module's setter +// does ("reconcile to an explicit chain... used when one surface must +// jump the other to an arbitrary focus", per the RFC) — renamed to match. +// The getter keeps its `sharedFocusChain` name rather than the RFC's bare +// `focusChain`: `widgets/composed-map/model/drill-state.ts` already +// exports an UNRELATED `focusChain(levelStack)` (a per-view LOCAL chain +// derivation) that both ComposedMapView.svelte and iso-view-state.svelte.ts +// import alongside this module — a bare `focusChain` here would collide +// with that import at every call site. `sharedFocusChain` disambiguates +// the two "focus chain" concepts by name, same as the file's own header +// paragraph above already does in prose. The RFC ALSO documents per-action +// `descend`/`ascend` and depth/visibility (`setDepth`/`toggleVisible`) as +// bus methods — those are NOT implemented here: depth-window and +// visibility are intentionally LOCAL-only state inside +// `iso-view-state.svelte.ts` (never shared), and each view's own +// descend/ascend mutates its OWN local levelStack, then mirrors the +// resulting full chain out via `focusTo` — a broadcast, not a per-action +// shared mutator. RFC-036's Function Signatures section still needs a +// follow-up edit to reflect this; that is Profile A/B territory (ADR/RFC +// ownership), not this coder change. import { untrack } from "svelte"; @@ -46,7 +69,7 @@ export function chainsEqual( // $effect (as both consuming views do) never makes that effect an // accidental subscriber of this module's own state — only the writes a // caller performs on ITS OWN local state should decide when it re-runs. -export function setSharedFocusChain(next: readonly string[]): void { +export function focusTo(next: readonly string[]): void { const current = untrack(() => chain); if (chainsEqual(current, next)) return; chain = [...next]; diff --git a/template/src/widgets/composed-map/ui/ComposedMapView.svelte b/template/src/widgets/composed-map/ui/ComposedMapView.svelte index 22d7ebb..82848a0 100644 --- a/template/src/widgets/composed-map/ui/ComposedMapView.svelte +++ b/template/src/widgets/composed-map/ui/ComposedMapView.svelte @@ -76,7 +76,7 @@ } from "@/widgets/composed-map/model/camera-bus.svelte"; import { sharedFocusChain, - setSharedFocusChain, + focusTo, chainsEqual, } from "@/widgets/composed-map/model/shared-drill-bus.svelte"; import type { ArtifactSummary } from "@/entities/artifact"; @@ -554,10 +554,10 @@ // // OUTBOUND — push this view's own chain whenever levelStack changes, from // ANY cause (click-descend, wheel-drill, Esc/ascend, breadcrumb climbTo). - // setSharedFocusChain no-ops on unchanged content, so this can never - // fight the INBOUND effect below into a loop. + // focusTo no-ops on unchanged content, so this can never fight the + // INBOUND effect below into a loop. $effect(() => { - setSharedFocusChain(focusChain(levelStack)); + focusTo(focusChain(levelStack)); }); // INBOUND — replay an externally-driven chain (e.g. a descend that diff --git a/template/src/widgets/dependency-graph/ui/IsoMapCorner.svelte b/template/src/widgets/dependency-graph/ui/IsoMapCorner.svelte index 288f128..ae8f6f6 100644 --- a/template/src/widgets/dependency-graph/ui/IsoMapCorner.svelte +++ b/template/src/widgets/dependency-graph/ui/IsoMapCorner.svelte @@ -27,7 +27,21 @@ {#await isoMapModule}
loading 3D…
{:then mod} - + + + + {#snippet failed()} +
3D minimap unavailable
+ {/snippet} +
{:catch}
3D minimap unavailable
{/await} diff --git a/template/src/widgets/iso-map/IsoScene.svelte b/template/src/widgets/iso-map/IsoScene.svelte index 0985cd7..8bb1ffc 100644 --- a/template/src/widgets/iso-map/IsoScene.svelte +++ b/template/src/widgets/iso-map/IsoScene.svelte @@ -34,13 +34,6 @@ disarmDwell, } from './model/iso-view-state.svelte'; - // TODO(spike): de-risking prototype for a future 3D isometric layered map - // view (IDEF0 exploded pyramid). Throwaway route, not linked from any nav — - // proves Threlte renders our real composed-map data as an orbit-able, - // click-to-descend 3D stack. Not the production composed-map (untouched). - // TODO(iso-promote): promote to widgets/iso-map + move shared drill logic to - // entities on graduation (see .claude/rules/10-comments-policy.md). - // // COMPOSITION ROOT (3D-only): camera + lights + interactivity() live // here; all geometry math is in lib/iso-projection.ts, all material/ // color tuning is in lib/iso-materials.ts, all interaction/animation diff --git a/template/src/widgets/iso-map/index.ts b/template/src/widgets/iso-map/index.ts index 5e2993e..24d9e61 100644 --- a/template/src/widgets/iso-map/index.ts +++ b/template/src/widgets/iso-map/index.ts @@ -1 +1,7 @@ +// TODO(iso-promote): the shared drill-chain logic this widget depends on +// (widgets/composed-map/model/shared-drill-bus.svelte.ts) is a lateral +// widget->widget import (FSD boundary note, RFC-036 Risk R-3) — move it to +// entities/ on a later graduation pass. This is the ONE canonical copy of +// this note (EVID-100 Finding #5); no other file under widgets/iso-map/ +// repeats it. export { default as IsoMinimap } from "./ui/IsoMinimap.svelte"; diff --git a/template/src/widgets/iso-map/lib/iso-materials.ts b/template/src/widgets/iso-map/lib/iso-materials.ts index e31869b..8a7a952 100644 --- a/template/src/widgets/iso-map/lib/iso-materials.ts +++ b/template/src/widgets/iso-map/lib/iso-materials.ts @@ -1,6 +1,3 @@ -// TODO(iso-promote): promote to widgets/iso-map + move shared drill logic to -// entities on graduation (see .claude/rules/10-comments-policy.md). -// // Token -> THREE.Color reader + tuned material constants for the iso-spike. // THEME-REACTIVE: `readIsoColors` must be called from inside a `$derived.by` // that ALSO reads `themeStore.tick` (see IsoScene.svelte), fixing the former diff --git a/template/src/widgets/iso-map/lib/iso-projection.ts b/template/src/widgets/iso-map/lib/iso-projection.ts index 4855c53..70bb827 100644 --- a/template/src/widgets/iso-map/lib/iso-projection.ts +++ b/template/src/widgets/iso-map/lib/iso-projection.ts @@ -1,6 +1,3 @@ -// TODO(iso-promote): promote to widgets/iso-map + move shared drill logic to -// entities on graduation (see .claude/rules/10-comments-policy.md). -// // Pure geometry/projection helpers for the iso-spike 3D layered map. No // Svelte, no THREE materials — only math + plain data (THREE.Vector3 is a // math primitive, not a material). Stage 2 (RFC-031-style generalization): diff --git a/template/src/widgets/iso-map/lib/leader-line.ts b/template/src/widgets/iso-map/lib/leader-line.ts index 65bf397..0b0adef 100644 --- a/template/src/widgets/iso-map/lib/leader-line.ts +++ b/template/src/widgets/iso-map/lib/leader-line.ts @@ -1,6 +1,3 @@ -// TODO(iso-promote): promote to widgets/iso-map + move shared drill logic to -// entities on graduation (see .claude/rules/10-comments-policy.md). -// // Pure geometry for IsoLeaderLine: clamps a point onto the nearest edge of // a DOMRect, so the leader line's card-side endpoint always lands ON the // card's border rather than floating inside it. No Svelte, no DOM globals diff --git a/template/src/widgets/iso-map/lib/motion.ts b/template/src/widgets/iso-map/lib/motion.ts index 25d0ca4..39df0fc 100644 --- a/template/src/widgets/iso-map/lib/motion.ts +++ b/template/src/widgets/iso-map/lib/motion.ts @@ -1,12 +1,10 @@ -// TODO(iso-promote): promote to widgets/iso-map + move shared drill logic to -// entities on graduation (see .claude/rules/10-comments-policy.md). -// // Route-local copy of widgets/dependency-graph/lib/reduced-motion.ts's // `motionDuration` helper. That file is not part of dependency-graph's // public index.ts barrel, so importing it directly would reach into // another widget's private lib/ (FSD encapsulation violation) — this route -// owns an identical, tiny copy instead until promotion (see header TODO) -// hoists a shared version both call sites can use. Safe on server: +// owns an identical, tiny copy instead until promotion (see +// widgets/iso-map/index.ts's canonical TODO(iso-promote)) hoists a shared +// version both call sites can use. Safe on server: // matchMedia is window-only, so it guards. export function motionDuration(defaultMs: number): number { if (typeof window === "undefined") return defaultMs; diff --git a/template/src/widgets/iso-map/model/iso-view-state.svelte.ts b/template/src/widgets/iso-map/model/iso-view-state.svelte.ts index cbb816d..9361738 100644 --- a/template/src/widgets/iso-map/model/iso-view-state.svelte.ts +++ b/template/src/widgets/iso-map/model/iso-view-state.svelte.ts @@ -1,6 +1,3 @@ -// TODO(iso-promote): promote to widgets/iso-map + move shared drill logic to -// entities on graduation (see .claude/rules/10-comments-policy.md). -// // Stage 2 — dynamic re-layering + collapse/animate + accordion depthWindow. // ALL new interaction/animation state for the iso-spike route lives here // (SRP): the levelStack (RFC-031's own LevelFrame, reused verbatim — 3D @@ -112,6 +109,36 @@ let animationKind = $state<"enter" | "exit" | null>(null); const enterProgress = new Tween(0, { duration: 0 }); const exitProgress = new Tween(1, { duration: 0 }); +// The most recently DROPPED external focus-chain target (see +// applyExternalFocusChain below) — recorded whenever a chain update from +// the shared drill-bus arrives while `animationKind !== null` and can't be +// applied yet. `$effect.root` gives this module its own detached reactive +// scope (no component owner, same shape Svelte's own testing docs use for +// a plain .svelte.ts module) that watches `animationKind` and retries the +// pending target the instant it settles back to `null` — so the 3D view +// always converges to the latest shared chain even if no FURTHER external +// chain change ever arrives (EVID-100 Finding #1 / RFC-036 NFR-004 / +// INV-E "no drift"). Re-running is always safe: `applyExternalFocusChain` +// is idempotent (a target that already matches the current chain is a +// no-op), and Svelte batches synchronous `animationKind` writes into one +// effect run, so a transient null in the middle of a still-running +// depthWindow resize (growDepthWindow/shrinkDepthWindow) never fires this +// watcher — only a genuine settle to idle does. +let pendingExternal: { + rootDoc: MapDocument; + target: readonly string[]; +} | null = null; + +$effect.root(() => { + $effect(() => { + if (animationKind !== null) return; + if (!pendingExternal) return; + const { rootDoc, target } = pendingExternal; + pendingExternal = null; + void applyExternalFocusChain(rootDoc, target); + }); +}); + export function currentLevelStack(): LevelFrame[] { return levelStack; } @@ -407,14 +434,21 @@ export function climbTo(index: number): void { // focusZone's own window-growing rule — "always show expanded"); never // shrinks it on ascend, since windowPlanes already caps the window at // however many docs actually exist, so a wide window over a short chain -// is harmless (it just means "show everything that's there"). Best-effort -// while an animation is already in flight — the caller's own effect will -// see the still-unreconciled chain and retry on the next change. +// is harmless (it just means "show everything that's there"). NOT +// best-effort: when `animationKind !== null` (either here or mid-loop +// below) the drop is recorded in `pendingExternal` and the module-level +// watcher above retries it the moment the in-flight animation settles, so +// this always converges even if the caller's own effect never fires again +// for this exact target (EVID-100 Finding #1). export async function applyExternalFocusChain( rootDoc: MapDocument, target: readonly string[], ): Promise { - if (animationKind !== null) return; + if (animationKind !== null) { + pendingExternal = { rootDoc, target }; + return; + } + pendingExternal = null; const local = focusChain(levelStack); let common = 0; while ( @@ -428,7 +462,10 @@ export async function applyExternalFocusChain( await collapseChainTo(common); } for (let i = common; i < target.length; i++) { - if (animationKind !== null) return; + if (animationKind !== null) { + pendingExternal = { rootDoc, target }; + return; + } await pushLevelAnimated(rootDoc, target[i]!); depthWindow = Math.min( 3, diff --git a/template/src/widgets/iso-map/ui/IsoA11yProxy.svelte b/template/src/widgets/iso-map/ui/IsoA11yProxy.svelte index bd5aa37..05d0431 100644 --- a/template/src/widgets/iso-map/ui/IsoA11yProxy.svelte +++ b/template/src/widgets/iso-map/ui/IsoA11yProxy.svelte @@ -1,7 +1,4 @@