@glossarist/concept-browser 0.7.90 → 0.7.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -27,6 +27,16 @@ import { useI18n } from '../i18n';
27
27
  const { t, locale } = useI18n();
28
28
  import type { Concept, Manifest, GraphEdge, PartitiveRelationWire, PartitiveMemberWire } from '../adapters/types';
29
29
  import { rakeStrokeStyle } from '../utils/partitive-multiplicity';
30
+ import { drawRakeBundles } from './relation-sphere/rake-bundles';
31
+ import { ensureMarkers, ensureTypeMarker } from './relation-sphere/svg-markers';
32
+ import {
33
+ sphereConstraint,
34
+ velocityClamp,
35
+ repulsion,
36
+ linkForce,
37
+ focusPin,
38
+ } from './relation-sphere/forces';
39
+ import type { ForcesContext } from './relation-sphere/types';
30
40
  import { conceptUri } from '../adapters/model-bridge';
31
41
 
32
42
  const props = defineProps<{
@@ -429,33 +439,6 @@ function buildGraph() {
429
439
 
430
440
  /* Reverse lookup: URI → node id. Linear scan is fine for ≤36 nodes. */
431
441
  /* ── Custom forces (EXACT prototype replica) ────────────── */
432
- function sphereConstraint() {
433
- let nL: SNode[] = nodes;
434
- function force() {
435
- for (const n of nL) {
436
- const len = Math.sqrt(n.x*n.x + n.y*n.y + n.z*n.z);
437
- if (len > 0.001) {
438
- const nx = n.x/len, ny = n.y/len, nz = n.z/len;
439
- n.x = nx; n.y = ny; n.z = nz;
440
- const rad = n.vx*nx + n.vy*ny + n.vz*nz;
441
- n.vx -= rad*nx; n.vy -= rad*ny; n.vz -= rad*nz;
442
- }
443
- }
444
- }
445
- (force as any).initialize = (n: SNode[]) => { nL = n; };
446
- return force;
447
- }
448
- function velocityClamp(maxV: number) {
449
- let nL: SNode[] = nodes;
450
- function force() {
451
- for (const n of nL) {
452
- const v2 = n.vx*n.vx + n.vy*n.vy + n.vz*n.vz;
453
- if (v2 > maxV*maxV) { const s = maxV/Math.sqrt(v2); n.vx*=s; n.vy*=s; n.vz*=s; }
454
- }
455
- }
456
- (force as any).initialize = (n: SNode[]) => { nL = n; };
457
- return force;
458
- }
459
442
  /* Pairwise repulsion — pushes nodes apart on the sphere surface so cards
460
443
  don't stack. Operates in 3D (chord distance), with the sphereConstraint
461
444
  normalizing positions back to the unit sphere each tick. Focus node is
@@ -472,31 +455,23 @@ let linkStrength = expandParams(5).linkStrength;
472
455
  let repulseMinDist = expandParams(5).repMin;
473
456
  let repulseStrength = expandParams(5).repStrength;
474
457
 
475
- function repulsion() {
476
- let nL: SNode[] = nodes;
477
- function force() {
478
- const minDist = repulseMinDist;
479
- const strength = repulseStrength;
480
- const min2 = minDist * minDist;
481
- for (let i = 0; i < nL.length; i++) {
482
- const a = nL[i];
483
- if (a.depth === 0) continue; /* focus pinned */
484
- for (let j = i + 1; j < nL.length; j++) {
485
- const b = nL[j];
486
- const dx = a.x - b.x, dy = a.y - b.y, dz = a.z - b.z;
487
- const d2 = dx*dx + dy*dy + dz*dz;
488
- if (d2 >= min2 || d2 < 1e-6) continue;
489
- const d = Math.sqrt(d2);
490
- const f = strength * (minDist - d) / d;
491
- const fx = dx * f, fy = dy * f, fz = dz * f;
492
- a.vx += fx; a.vy += fy; a.vz += fz;
493
- if (b.depth !== 0) { b.vx -= fx; b.vy -= fy; b.vz -= fz; }
494
- }
495
- }
496
- }
497
- (force as any).initialize = (n: SNode[]) => { nL = n; };
498
- return force;
499
- }
458
+ /**
459
+ * Read-only getter bundle for the extracted force factories in
460
+ * ./relation-sphere/forces.ts. Forces read the mutable sim state
461
+ * via these getters each tick so user input (Expand slider, mute
462
+ * toggles, navigation target) takes effect on the next step.
463
+ */
464
+ const forcesContext: ForcesContext = {
465
+ nodes: () => nodes,
466
+ links: () => links,
467
+ mutedTypes: () => mutedTypes.value,
468
+ mutedRegisters: () => mutedRegisters.value,
469
+ linkDistance: () => linkDistance,
470
+ linkStrength: () => linkStrength,
471
+ repulseMinDist: () => repulseMinDist,
472
+ repulseStrength: () => repulseStrength,
473
+ };
474
+
500
475
  const navForce = (() => {
501
476
  let nL: SNode[] = nodes;
502
477
  function force() {
@@ -524,63 +499,16 @@ const clampF = velocityClamp(0.20);
524
499
  /* minDist=0.95 ≈ 56° separation on the unit sphere — strong spread so
525
500
  cards don't cluster around the focus. strength is high enough that
526
501
  nodes reach their spread positions within ~2 seconds of sim start. */
527
- const repulseF = repulsion();
502
+ const repulseF = repulsion(forcesContext);
528
503
 
529
504
  /* 3D spring (link) force — pulls connected nodes toward a target chord
530
505
  distance. This is what actually spreads cards apart: longer springs
531
506
  (higher Expand level) push the graph outward along its edges. Stock
532
507
  d3-forceLink only handles x/y; this custom version operates in full
533
508
  3D so the sphereConstraint can renormalize positions correctly. */
534
- function linkForce() {
535
- let nL: SNode[] = nodes;
536
- let idToIdx = new Map<string, number>();
537
- function force() {
538
- if (links.length === 0) return;
539
- const target = linkDistance;
540
- const strength = linkStrength;
541
- /* Read muted sets each tick so toggleType/toggleRegister take effect
542
- immediately on the next simulation step. */
543
- const mutedT = mutedTypes.value;
544
- const mutedR = mutedRegisters.value;
545
- for (const link of links) {
546
- if (mutedT.has(link.type)) continue;
547
- const ai = idToIdx.get(link.source);
548
- const bi = idToIdx.get(link.target);
549
- if (ai === undefined || bi === undefined) continue;
550
- const a = nL[ai], b = nL[bi];
551
- /* Skip edges involving a muted register (unless the node is the focus) */
552
- if (a.depth !== 0 && mutedR.has(a.register)) continue;
553
- if (b.depth !== 0 && mutedR.has(b.register)) continue;
554
- const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
555
- const d2 = dx*dx + dy*dy + dz*dz;
556
- if (d2 < 1e-6) continue;
557
- const d = Math.sqrt(d2);
558
- const diff = (d - target) / d * strength;
559
- const fx = dx * diff, fy = dy * diff, fz = dz * diff;
560
- if (a.depth !== 0) { a.vx += fx; a.vy += fy; a.vz += fz; }
561
- if (b.depth !== 0) { b.vx -= fx; b.vy -= fy; b.vz -= fz; }
562
- }
563
- }
564
- (force as any).initialize = (n: SNode[]) => {
565
- nL = n;
566
- idToIdx = new Map();
567
- for (let i = 0; i < n.length; i++) idToIdx.set(n[i].id, i);
568
- };
569
- return force;
570
- }
571
- const linkF = linkForce();
509
+ const linkF = linkForce(forcesContext);
572
510
  /* Pin the focus node at (0, 0, 1) so neighbor repulsion doesn't shove it.
573
511
  Without this, the focus drifts and the whole sphere wobbles. */
574
- function focusPin() {
575
- let nL: SNode[] = nodes;
576
- function force() {
577
- for (const n of nL) {
578
- if (n.depth === 0) { n.x = 0; n.y = 0; n.z = 1; n.vx = 0; n.vy = 0; n.vz = 0; }
579
- }
580
- }
581
- (force as any).initialize = (n: SNode[]) => { nL = n; };
582
- return force;
583
- }
584
512
  const focusPinF = focusPin();
585
513
 
586
514
  /* ── Simulation ─────────────────────────────────────────── */
@@ -757,218 +685,12 @@ function drawEdges(cx: number, cy: number) {
757
685
  svg.appendChild(label);
758
686
  }
759
687
 
760
- drawRakeBundles(svg, pos, cx, cy);
761
- }
762
-
763
- /**
764
- * Render PartitiveRelations as ISO 704:2022 rake brackets in the sphere.
765
- *
766
- * Each relation renders as a right-angle bracket with stem + spine + drops
767
- * (geometry described in detail above). Stem and spine are the structural
768
- * frame — always single solid at FRAME_STROKE_WIDTH.
769
- *
770
- * Per-member multiplicity + is_delimiting is encoded on each DROP via
771
- * rakeStrokeStyle() (see utils/partitive-multiplicity.ts):
772
- *
773
- * multiplicity is_delimiting drop visual
774
- * ------------------- -------------- --------------------------------
775
- * compulsory false 1 solid @ 1.5px
776
- * compulsory true 1 solid @ 4.5px (3× bold)
777
- * optional false 1 dashed @ 1.5px
778
- * optional true 1 dashed @ 4.5px
779
- * compulsory_multiple false 2 solid @ 1.5px
780
- * compulsory_multiple true 2 solid @ 4.5px
781
- * optional_multiple false 2 dashed @ 1.5px
782
- * optional_multiple true 2 dashed @ 4.5px
783
- * compulsory_at_least_one false 1 solid + 1 dashed @ 1.5px
784
- * compulsory_at_least_one true 1 solid + 1 dashed @ 4.5px
785
- *
786
- * completeness === 'partial' : spine extends past last tooth (continued
787
- * backline — "more exist but not shown")
788
- *
789
- * Diamond marker at the comprehensive end signals the hyperedge origin.
790
- * Junction marker at the spine midpoint signals the right-angle corner.
791
- */
792
- function drawRakeBundles(
793
- svg: SVGSVGElement,
794
- pos: Map<string, {x: number; y: number}>,
795
- _cx: number,
796
- _cy: number,
797
- ) {
798
- if (mutedTypes.value.has('__partitive__')) return;
799
- const color = uiStore.isDark ? '#2dd4bf' : '#0d9488';
800
- const DOUBLE_GAP = 4;
801
- const DASH = '4 3';
802
- const FRAME_STROKE_WIDTH = 1.4;
803
- const SPINE_FRACTION = 0.55; // spine sits 55% from comp toward centroid
804
- const PARTIAL_TAIL = 28; // px extension for completeness: 'partial'
805
-
806
- for (const rel of props.partitiveRelations ?? []) {
807
- const compParsed = UriRouter.parseUri(rel.comprehensive);
808
- if (!compParsed) continue;
809
- const compNodeId = `${compParsed.registerId}/${compParsed.conceptId}`;
810
- const compPos = pos.get(compNodeId);
811
- if (!compPos) continue;
812
-
813
- const memberPositions = rel.partitives
814
- .map(member => {
815
- const parsed = UriRouter.parseUri(member.uri);
816
- if (!parsed) return null;
817
- const nid = `${parsed.registerId}/${parsed.conceptId}`;
818
- const p = pos.get(nid);
819
- return p ? { member, pos: p } : null;
820
- })
821
- .filter((x): x is { member: PartitiveMemberWire; pos: {x: number; y: number} } => x !== null);
822
-
823
- if (memberPositions.length === 0) continue;
824
-
825
- // 1. Compute stem-axis (comp → centroid of partitives) and spine-axis (perpendicular)
826
- const centroidX = memberPositions.reduce((s, m) => s + m.pos.x, 0) / memberPositions.length;
827
- const centroidY = memberPositions.reduce((s, m) => s + m.pos.y, 0) / memberPositions.length;
828
- const stemDx = centroidX - compPos.x;
829
- const stemDy = centroidY - compPos.y;
830
- const stemLen = Math.hypot(stemDx, stemDy) || 1;
831
- const stemDirX = stemDx / stemLen; // unit vector along stem
832
- const stemDirY = stemDy / stemLen;
833
- const spineDirX = -stemDirY; // perpendicular = spine direction
834
- const spineDirY = stemDirX;
835
-
836
- // 2. Spine center: along stem-axis at SPINE_FRACTION from comp
837
- const spineCx = compPos.x + stemDirX * stemLen * SPINE_FRACTION;
838
- const spineCy = compPos.y + stemDirY * stemLen * SPINE_FRACTION;
839
-
840
- // 3. Tooth positions = projection of each partitive onto spine line
841
- const teeth = memberPositions.map(({ member, pos: pPos }) => {
842
- const vx = pPos.x - spineCx;
843
- const vy = pPos.y - spineCy;
844
- const along = vx * spineDirX + vy * spineDirY;
845
- const toothX = spineCx + spineDirX * along;
846
- const toothY = spineCy + spineDirY * along;
847
- return {
848
- member,
849
- partitivePos: pPos,
850
- toothX,
851
- toothY,
852
- along,
853
- style: rakeStrokeStyle(member.presence, member.count, member.isDelimiting),
854
- };
855
- });
856
-
857
- // 4. Spine extents (leftmost to rightmost tooth along spine-axis)
858
- let minAlong = teeth[0].along;
859
- let maxAlong = teeth[0].along;
860
- for (const t of teeth) {
861
- if (t.along < minAlong) minAlong = t.along;
862
- if (t.along > maxAlong) maxAlong = t.along;
863
- }
864
- // For completeness: 'partial', extend past last tooth (continued backline)
865
- if (rel.completeness === 'partial') maxAlong += PARTIAL_TAIL;
866
- const spineStartX = spineCx + spineDirX * minAlong;
867
- const spineStartY = spineCy + spineDirY * minAlong;
868
- const spineEndX = spineCx + spineDirX * maxAlong;
869
- const spineEndY = spineCy + spineDirY * maxAlong;
870
-
871
- /**
872
- * Draw a single straight segment.
873
- *
874
- * Stem + spine use FRAME_STROKE_WIDTH (single solid frame).
875
- * Drops use the per-member multiplicity + delimiting style from
876
- * rakeStrokeStyle().
877
- */
878
- const drawSegment = (
879
- ax: number, ay: number, bx: number, by: number,
880
- opts: { width: number; dashed?: boolean; offset?: number; opacity?: number },
881
- ) => {
882
- const opacity = opts.opacity ?? 0.85;
883
- const offset = opts.offset ?? 0;
884
- const dashed = opts.dashed ?? false;
885
- const dx = bx - ax, dy = by - ay;
886
- const len = Math.hypot(dx, dy) || 1;
887
- const px = -dy / len, py = dx / len;
888
- const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
889
- line.setAttribute('class', 'rake-seg');
890
- line.setAttribute('x1', String(ax + px * offset));
891
- line.setAttribute('y1', String(ay + py * offset));
892
- line.setAttribute('x2', String(bx + px * offset));
893
- line.setAttribute('y2', String(by + py * offset));
894
- line.setAttribute('stroke', color);
895
- line.setAttribute('stroke-width', String(opts.width));
896
- line.setAttribute('opacity', String(opacity));
897
- line.setAttribute('stroke-linecap', 'square');
898
- if (dashed) line.setAttribute('stroke-dasharray', DASH);
899
- svg.appendChild(line);
900
- };
901
-
902
- // 5. Stem: comp → spine center (frame — single solid)
903
- drawSegment(compPos.x, compPos.y, spineCx, spineCy, { width: FRAME_STROKE_WIDTH });
904
-
905
- // 6. Spine: leftmost tooth → rightmost tooth (frame — single solid)
906
- if (Math.abs(maxAlong - minAlong) > 1) {
907
- drawSegment(spineStartX, spineStartY, spineEndX, spineEndY, { width: FRAME_STROKE_WIDTH });
908
- }
909
-
910
- // 7. Drops: tooth → partitive (per-member multiplicity + delimiting)
911
- // Draw 1 or 2 parallel lines per the style.
912
- for (const tooth of teeth) {
913
- drawSegment(tooth.toothX, tooth.toothY, tooth.partitivePos.x, tooth.partitivePos.y, {
914
- width: tooth.style.strokeWidth,
915
- dashed: tooth.style.primaryDashed,
916
- });
917
- if (tooth.style.lineCount === 2) {
918
- drawSegment(tooth.toothX, tooth.toothY, tooth.partitivePos.x, tooth.partitivePos.y, {
919
- width: tooth.style.strokeWidth,
920
- dashed: tooth.style.secondaryDashed,
921
- offset: DOUBLE_GAP,
922
- });
923
- }
924
- }
925
-
926
- // 8. Junction marker at spine center (the right-angle corner)
927
- const junction = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
928
- junction.setAttribute('class', 'rake-junction');
929
- junction.setAttribute('cx', String(spineCx));
930
- junction.setAttribute('cy', String(spineCy));
931
- junction.setAttribute('r', '2.5');
932
- junction.setAttribute('fill', color);
933
- junction.setAttribute('opacity', '0.9');
934
- junction.setAttribute('stroke', uiStore.isDark ? '#0a1f1c' : '#ffffff');
935
- junction.setAttribute('stroke-width', '1');
936
- svg.appendChild(junction);
937
-
938
- // 9. Diamond marker at comprehensive end (hyperedge origin)
939
- const diamond = document.createElementNS('http://www.w3.org/2000/svg', 'path');
940
- const dsize = 5;
941
- diamond.setAttribute('d',
942
- `M ${compPos.x} ${compPos.y - dsize} L ${compPos.x + dsize} ${compPos.y} L ${compPos.x} ${compPos.y + dsize} L ${compPos.x - dsize} ${compPos.y} Z`);
943
- diamond.setAttribute('fill', color);
944
- diamond.setAttribute('opacity', '0.9');
945
- diamond.setAttribute('stroke', uiStore.isDark ? '#0a1f1c' : '#ffffff');
946
- diamond.setAttribute('stroke-width', '1');
947
- svg.appendChild(diamond);
948
- }
949
- }
950
-
951
- function ensureMarkers(svg: SVGSVGElement) {
952
- let defs = svg.querySelector('defs');
953
- if (!defs) { defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); svg.appendChild(defs); }
954
- }
955
-
956
- /* Lazy per-type marker — arrow color matches the edge's per-type color
957
- (which may differ from the category color via TYPE_COLOR_OVERRIDE). */
958
- function ensureTypeMarker(svg: SVGSVGElement, typeId: string, color: string): string {
959
- const safeId = typeId.replace(/[^a-zA-Z0-9_-]/g, '_');
960
- const markerId = `rel-arrow-t-${safeId}`;
961
- if (svg.querySelector(`#${markerId}`)) return markerId;
962
- let defs = svg.querySelector('defs');
963
- if (!defs) { defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs'); svg.appendChild(defs); }
964
- const m = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
965
- m.setAttribute('id', markerId);
966
- m.setAttribute('viewBox', '0 0 8 8'); m.setAttribute('refX', '6'); m.setAttribute('refY', '4');
967
- m.setAttribute('markerWidth', '5'); m.setAttribute('markerHeight', '5'); m.setAttribute('orient', 'auto');
968
- const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
969
- p.setAttribute('d', 'M 0 0 L 6 4 L 0 8 z'); p.setAttribute('fill', color);
970
- m.appendChild(p); defs.appendChild(m);
971
- return markerId;
688
+ drawRakeBundles(svg, pos, {
689
+ isDark: uiStore.isDark,
690
+ color: uiStore.isDark ? '#2dd4bf' : '#0d9488',
691
+ isMuted: mutedTypes.value.has('__partitive__'),
692
+ relations: props.partitiveRelations ?? [],
693
+ });
972
694
  }
973
695
 
974
696
  /* ── Render DOM nodes ───────────────────────────────────── */