@glossarist/concept-browser 0.7.83 → 0.7.85

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glossarist/concept-browser",
3
- "version": "0.7.83",
3
+ "version": "0.7.85",
4
4
  "description": "Vue SPA for browsing Glossarist terminology datasets with cross-reference resolution, graph visualization, and multi-language support",
5
5
  "type": "module",
6
6
  "bin": {
@@ -537,11 +537,19 @@ function linkForce() {
537
537
  if (links.length === 0) return;
538
538
  const target = linkDistance;
539
539
  const strength = linkStrength;
540
+ /* Read muted sets each tick so toggleType/toggleRegister take effect
541
+ immediately on the next simulation step. */
542
+ const mutedT = mutedTypes.value;
543
+ const mutedR = mutedRegisters.value;
540
544
  for (const link of links) {
545
+ if (mutedT.has(link.type)) continue;
541
546
  const ai = idToIdx.get(link.source);
542
547
  const bi = idToIdx.get(link.target);
543
548
  if (ai === undefined || bi === undefined) continue;
544
549
  const a = nL[ai], b = nL[bi];
550
+ /* Skip edges involving a muted register (unless the node is the focus) */
551
+ if (a.depth !== 0 && mutedR.has(a.register)) continue;
552
+ if (b.depth !== 0 && mutedR.has(b.register)) continue;
545
553
  const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
546
554
  const d2 = dx*dx + dy*dy + dz*dz;
547
555
  if (d2 < 1e-6) continue;
@@ -630,8 +638,10 @@ function drawEdges(cx: number, cy: number) {
630
638
  svg.setAttribute('height', String(h));
631
639
  svg.setAttribute('viewBox', `0 0 ${w} ${h}`);
632
640
  svg.setAttribute('preserveAspectRatio', 'none');
633
- /* Remove only path + text + rect (edges), keep defs (markers) */
634
- svg.querySelectorAll('path:not(defs path), text.edge-label, rect.edge-label-bg').forEach(el => el.remove());
641
+ /* Remove only path + text + rect (edges), keep defs (markers).
642
+ Also remove <line> elements (rake-bundle segments) and <circle>
643
+ (rake junction markers). */
644
+ svg.querySelectorAll('path:not(defs path), line.rake-seg, circle.rake-junction, text.edge-label, rect.edge-label-bg').forEach(el => el.remove());
635
645
  if (!svg.querySelector('defs')) ensureMarkers(svg);
636
646
 
637
647
  /* Compute visible node positions */
@@ -750,21 +760,41 @@ function drawEdges(cx: number, cy: number) {
750
760
  }
751
761
 
752
762
  /**
753
- * Render PartitiveRelations as ISO 704 rake bundles in the sphere.
763
+ * Render PartitiveRelations as ISO 704 right-angle rake brackets.
754
764
  *
755
- * In 3D space the literal "right-angle bracket" rake doesn't apply,
756
- * so we use a bundle of N curves fanning out from the comprehensive,
757
- * all sharing visual styling. The bundle communicates "these N edges
758
- * are ONE relation", not N independent pairwise links.
765
+ * Each relation renders as a true bracket with 90° angles:
759
766
  *
760
- * ISO 704 line conventions applied:
761
- * - default : single solid curve per member
762
- * - plurality.isShared && !isUncertain : two parallel solid curves (close-set)
763
- * - plurality.isShared && isUncertain : one solid + one dashed curve
764
- * - per-member certainty: 'possible' : the member's curve is dashed
765
- * (regardless of plurality)
767
+ * [comprehensive]
768
+ * │
769
+ * │ ← stem (along stem-axis)
770
+ * │
771
+ * ┌──────────●──────────┐ ← spine (perpendicular to stem-axis; right angle at junction)
772
+ * │ │ │
773
+ * │ │ │ ← drops (parallel to stem-axis; right angle at each tooth)
774
+ * [p1] [p2] [p3]
766
775
  *
767
- * A diamond marker at the comprehensive end signals the hyperedge origin.
776
+ * Geometry:
777
+ * 1. stem-axis = direction from comprehensive to centroid of partitives
778
+ * 2. spine-axis = perpendicular to stem-axis
779
+ * 3. spine-center = point along stem-axis at SPINE_FRACTION (60%) from comp
780
+ * 4. For each partitive Pi:
781
+ * - Project Pi onto spine-axis → tooth position
782
+ * - Drop goes from tooth straight to Pi (parallel to stem-axis by construction)
783
+ *
784
+ * All bends are exactly 90° because tooth_i is the foot of perpendicular
785
+ * from Pi to the spine line.
786
+ *
787
+ * ISO 704 line conventions:
788
+ * - default : single solid line per segment
789
+ * - plurality.isShared && !isUncertain : TWO parallel solid lines (close-set)
790
+ * - plurality.isShared && isUncertain : one solid + one dashed line
791
+ * - completeness === 'partial' : spine extends past last tooth
792
+ * (continued backline — "more exist")
793
+ *
794
+ * Per-member certainty 'possible' → that drop is dashed + reduced opacity
795
+ * (stem and spine keep relation style).
796
+ *
797
+ * Diamond marker at the comprehensive end signals the hyperedge origin.
768
798
  */
769
799
  function drawRakeBundles(
770
800
  svg: SVGSVGElement,
@@ -772,6 +802,13 @@ function drawRakeBundles(
772
802
  _cx: number,
773
803
  _cy: number,
774
804
  ) {
805
+ if (mutedTypes.value.has('__partitive__')) return;
806
+ const color = uiStore.isDark ? '#2dd4bf' : '#0d9488';
807
+ const DOUBLE_GAP = 4;
808
+ const DASH = '4 3';
809
+ const SPINE_FRACTION = 0.55; // spine sits 55% from comp toward centroid
810
+ const PARTIAL_TAIL = 28; // px extension for completeness: 'partial'
811
+
775
812
  for (const rel of props.partitiveRelations ?? []) {
776
813
  const compParsed = UriRouter.parseUri(rel.comprehensive);
777
814
  if (!compParsed) continue;
@@ -791,58 +828,135 @@ function drawRakeBundles(
791
828
 
792
829
  if (memberPositions.length === 0) continue;
793
830
 
794
- const color = uiStore.isDark ? '#2dd4bf' : '#0d9488'; // teal — matches sidebar rake
795
- const DOUBLE_GAP = 4;
796
- const DASH = '4 3';
831
+ // 1. Compute stem-axis (comp → centroid of partitives) and spine-axis (perpendicular)
832
+ const centroidX = memberPositions.reduce((s, m) => s + m.pos.x, 0) / memberPositions.length;
833
+ const centroidY = memberPositions.reduce((s, m) => s + m.pos.y, 0) / memberPositions.length;
834
+ const stemDx = centroidX - compPos.x;
835
+ const stemDy = centroidY - compPos.y;
836
+ const stemLen = Math.hypot(stemDx, stemDy) || 1;
837
+ const stemDirX = stemDx / stemLen; // unit vector along stem
838
+ const stemDirY = stemDy / stemLen;
839
+ const spineDirX = -stemDirY; // perpendicular = spine direction
840
+ const spineDirY = stemDirX;
841
+
842
+ // 2. Spine center: along stem-axis at SPINE_FRACTION from comp
843
+ const spineCx = compPos.x + stemDirX * stemLen * SPINE_FRACTION;
844
+ const spineCy = compPos.y + stemDirY * stemLen * SPINE_FRACTION;
845
+
846
+ // 3. Tooth positions = projection of each partitive onto spine line
847
+ const teeth = memberPositions.map(({ member, pos: pPos }) => {
848
+ const vx = pPos.x - spineCx;
849
+ const vy = pPos.y - spineCy;
850
+ const along = vx * spineDirX + vy * spineDirY;
851
+ const toothX = spineCx + spineDirX * along;
852
+ const toothY = spineCy + spineDirY * along;
853
+ return { member, partitivePos: pPos, toothX, toothY, along };
854
+ });
855
+
856
+ // 4. Spine extents (leftmost to rightmost tooth along spine-axis)
857
+ let minAlong = teeth[0].along;
858
+ let maxAlong = teeth[0].along;
859
+ for (const t of teeth) {
860
+ if (t.along < minAlong) minAlong = t.along;
861
+ if (t.along > maxAlong) maxAlong = t.along;
862
+ }
863
+ // For completeness: 'partial', extend past last tooth (continued backline)
864
+ if (rel.completeness === 'partial') maxAlong += PARTIAL_TAIL;
865
+ const spineStartX = spineCx + spineDirX * minAlong;
866
+ const spineStartY = spineCy + spineDirY * minAlong;
867
+ const spineEndX = spineCx + spineDirX * maxAlong;
868
+ const spineEndY = spineCy + spineDirY * maxAlong;
869
+
797
870
  const isShared = !!rel.plurality?.isShared;
798
871
  const isUncertain = !!rel.plurality?.isUncertain;
799
- const drawDouble = isShared;
800
- const secondDashed = isUncertain;
801
872
 
802
- for (const { member, pos: pPos } of memberPositions) {
803
- const dx = pPos.x - compPos.x;
804
- const dy = pPos.y - compPos.y;
873
+ /**
874
+ * Draw a single straight segment with optional parallel offset
875
+ * (used to render the close-set double line) and dash style.
876
+ */
877
+ const drawSegment = (
878
+ ax: number, ay: number, bx: number, by: number,
879
+ opts: { offset?: number; dashed?: boolean; opacity?: number } = {},
880
+ ) => {
881
+ const offset = opts.offset ?? 0;
882
+ const dashed = opts.dashed ?? false;
883
+ const opacity = opts.opacity ?? 0.85;
884
+ const dx = bx - ax, dy = by - ay;
805
885
  const len = Math.hypot(dx, dy) || 1;
806
- const px = -dy / len; // perpendicular unit
807
- const py = dx / len;
808
-
809
- const drawLine = (offset: number, dashed: boolean) => {
810
- const ax = compPos.x + px * offset;
811
- const ay = compPos.y + py * offset;
812
- const bx = pPos.x + px * offset;
813
- const by = pPos.y + py * offset;
814
- const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
815
- // Quadratic bezier toward a control point biased outward from the focus
816
- // to suggest the "rake handle" shape.
817
- const mx = (ax + bx) / 2;
818
- const my = (ay + by) / 2;
819
- const ctrlLen = len * 0.15;
820
- path.setAttribute('d', `M ${ax} ${ay} Q ${mx + px * ctrlLen} ${my + py * ctrlLen} ${bx} ${by}`);
821
- path.setAttribute('stroke', color);
822
- path.setAttribute('stroke-width', '1.5');
823
- path.setAttribute('opacity', member.certainty === 'possible' ? '0.45' : '0.8');
824
- path.setAttribute('fill', 'none');
825
- path.setAttribute('stroke-linecap', 'round');
826
- if (dashed || member.certainty === 'possible') {
827
- path.setAttribute('stroke-dasharray', DASH);
828
- }
829
- svg.appendChild(path);
830
- };
886
+ // Perpendicular to THIS segment (not the spine-axis) for parallel offset
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', '1.6');
896
+ line.setAttribute('opacity', String(opacity));
897
+ line.setAttribute('stroke-linecap', 'square'); // sharp corners at right angles
898
+ if (dashed) line.setAttribute('stroke-dasharray', DASH);
899
+ svg.appendChild(line);
900
+ };
831
901
 
832
- drawLine(0, false);
833
- if (drawDouble) {
834
- drawLine(DOUBLE_GAP, secondDashed);
835
- drawLine(-DOUBLE_GAP, secondDashed);
902
+ /**
903
+ * Draw a segment with the relation's plurality style.
904
+ * Single solid (default), double solid (isShared),
905
+ * or solid + dashed (isShared + isUncertain).
906
+ */
907
+ const drawSegmentWithPlurality = (
908
+ ax: number, ay: number, bx: number, by: number,
909
+ opts: { opacity?: number; perMemberPossible?: boolean } = {},
910
+ ) => {
911
+ const opacity = opts.opacity ?? 0.85;
912
+ if (!isShared) {
913
+ drawSegment(ax, ay, bx, by, { opacity, dashed: opts.perMemberPossible });
914
+ } else {
915
+ drawSegment(ax, ay, bx, by, { offset: -DOUBLE_GAP / 2, opacity });
916
+ drawSegment(ax, ay, bx, by, {
917
+ offset: DOUBLE_GAP / 2,
918
+ opacity,
919
+ dashed: isUncertain || opts.perMemberPossible,
920
+ });
836
921
  }
922
+ };
923
+
924
+ // 5. Stem: comp → spine center (along stem-axis)
925
+ drawSegmentWithPlurality(compPos.x, compPos.y, spineCx, spineCy);
926
+
927
+ // 6. Spine: leftmost tooth → rightmost tooth (along spine-axis)
928
+ // (skip if only one tooth — no horizontal extent)
929
+ if (Math.abs(maxAlong - minAlong) > 1) {
930
+ drawSegmentWithPlurality(spineStartX, spineStartY, spineEndX, spineEndY);
931
+ }
932
+
933
+ // 7. Drops: tooth → partitive (parallel to stem-axis by construction)
934
+ for (const tooth of teeth) {
935
+ drawSegmentWithPlurality(tooth.toothX, tooth.toothY, tooth.partitivePos.x, tooth.partitivePos.y, {
936
+ opacity: tooth.member.certainty === 'possible' ? 0.5 : 0.85,
937
+ perMemberPossible: tooth.member.certainty === 'possible',
938
+ });
837
939
  }
838
940
 
839
- /* Diamond marker at comprehensive end — signals "rake origin" */
941
+ // 8. Junction marker at spine center (the right-angle corner)
942
+ const junction = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
943
+ junction.setAttribute('class', 'rake-junction');
944
+ junction.setAttribute('cx', String(spineCx));
945
+ junction.setAttribute('cy', String(spineCy));
946
+ junction.setAttribute('r', '2.5');
947
+ junction.setAttribute('fill', color);
948
+ junction.setAttribute('opacity', '0.9');
949
+ junction.setAttribute('stroke', uiStore.isDark ? '#0a1f1c' : '#ffffff');
950
+ junction.setAttribute('stroke-width', '1');
951
+ svg.appendChild(junction);
952
+
953
+ // 9. Diamond marker at comprehensive end (hyperedge origin)
840
954
  const diamond = document.createElementNS('http://www.w3.org/2000/svg', 'path');
841
955
  const dsize = 5;
842
956
  diamond.setAttribute('d',
843
957
  `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`);
844
958
  diamond.setAttribute('fill', color);
845
- diamond.setAttribute('opacity', '0.85');
959
+ diamond.setAttribute('opacity', '0.9');
846
960
  diamond.setAttribute('stroke', uiStore.isDark ? '#0a1f1c' : '#ffffff');
847
961
  diamond.setAttribute('stroke-width', '1');
848
962
  svg.appendChild(diamond);
@@ -1173,6 +1287,8 @@ watch(() => [props.concept?.id, props.registerId] as const, ([newId, newReg]) =>
1173
1287
  /* ── Legend — grouped by relation TYPE (not category) ───── */
1174
1288
  const legendItems = computed(() => {
1175
1289
  graphVersion.value; /* reactive dependency */
1290
+ uiStore.isDark; /* re-evaluate when dark mode toggles (color depends on it) */
1291
+ mutedTypes.value; /* re-evaluate when mute set changes */
1176
1292
  const typeMap = new Map<string, { type: string; label: string; color: string; count: number }>();
1177
1293
  for (const link of links) {
1178
1294
  /* Per-type color (with override) so e.g. 'see' and 'references' differ */
@@ -1185,6 +1301,18 @@ const legendItems = computed(() => {
1185
1301
  }
1186
1302
  typeMap.get(key)!.count++;
1187
1303
  }
1304
+ /* Synthetic entry for PartitiveRelations (hyperedge rake bundles).
1305
+ These don't appear in `links` (they're rendered separately), so we
1306
+ inject a legend entry when any are present. */
1307
+ const partitiveCount = props.partitiveRelations?.length ?? 0;
1308
+ if (partitiveCount > 0) {
1309
+ typeMap.set('__partitive__', {
1310
+ type: '__partitive__',
1311
+ label: 'partitive relation',
1312
+ color: uiStore.isDark ? '#2dd4bf' : '#0d9488',
1313
+ count: partitiveCount,
1314
+ });
1315
+ }
1188
1316
  return Array.from(typeMap.values()).sort((a, b) => b.count - a.count);
1189
1317
  });
1190
1318
 
@@ -1209,6 +1337,9 @@ function toggleRegister(reg: string) {
1209
1337
  renderDOM();
1210
1338
  const c = canvasRef.value;
1211
1339
  if (c) drawEdges(c.clientWidth / 2, c.clientHeight * 0.46);
1340
+ /* Re-heat the simulation so nodes "feel" the freed/hidden edges and
1341
+ drift to a new equilibrium instead of staying frozen. */
1342
+ if (sim) sim.alpha(0.5).restart();
1212
1343
  }
1213
1344
 
1214
1345
  async function changeDegree(d: 1 | 2 | 3) {
@@ -1326,6 +1457,10 @@ function toggleType(type: string) {
1326
1457
  mutedTypes.value = n;
1327
1458
  const c = canvasRef.value;
1328
1459
  if (c) drawEdges(c.clientWidth / 2, c.clientHeight * 0.46);
1460
+ /* Re-heat the simulation so nodes drift to a new equilibrium with
1461
+ the freed/hidden edges. The link force reads mutedTypes each tick,
1462
+ so toggling effectively "removes" the edge from the force calc. */
1463
+ if (sim) sim.alpha(0.5).restart();
1329
1464
  }
1330
1465
 
1331
1466
  function isTypeMuted(type: string): boolean {
@@ -1498,9 +1633,12 @@ defineExpose({ navigate });
1498
1633
  <div class="sp-section-label">{{ t('sphere.relationTypes') }}</div>
1499
1634
  <div class="sp-legend-grid">
1500
1635
  <button v-for="item in legendItems" :key="item.type" class="sp-legend-item"
1501
- :class="{ muted: isTypeMuted(item.type) }" @click="toggleType(item.type)"
1636
+ :class="{ muted: isTypeMuted(item.type), 'is-partitive': item.type === '__partitive__' }"
1637
+ @click="toggleType(item.type)"
1502
1638
  :title="item.type">
1503
- <span class="sp-swatch" :style="{ background: isTypeMuted(item.type) ? '#b8b9cc' : item.color }"></span>
1639
+ <span v-if="item.type === '__partitive__'" class="sp-swatch sp-swatch-rake"
1640
+ :style="{ '--rake-color': isTypeMuted(item.type) ? '#b8b9cc' : item.color }"></span>
1641
+ <span v-else class="sp-swatch" :style="{ background: isTypeMuted(item.type) ? '#b8b9cc' : item.color }"></span>
1504
1642
  <span>{{ item.label }}</span>
1505
1643
  <span class="sp-count">{{ item.count }}</span>
1506
1644
  </button>
@@ -1749,6 +1887,26 @@ defineExpose({ navigate });
1749
1887
  .sp-legend-item:hover { background: var(--rule); }
1750
1888
  .sp-legend-item.muted { opacity: 0.35; }
1751
1889
  .sp-swatch { width: 18px; height: 2px; flex-shrink: 0; border-radius: 1px; }
1890
+ /* Rake swatch — small bracket icon signaling a PartitiveRelation hyperedge
1891
+ (not a single binary edge). Two parallel solid lines (close-set double)
1892
+ with a perpendicular stem, evoking the ISO 704 rake convention. */
1893
+ .sp-swatch-rake {
1894
+ width: 18px; height: 10px; position: relative; background: transparent !important;
1895
+ border-radius: 0;
1896
+ }
1897
+ .sp-swatch-rake::before,
1898
+ .sp-swatch-rake::after {
1899
+ content: ''; position: absolute; background: var(--rake-color, #0d9488);
1900
+ }
1901
+ .sp-swatch-rake::before {
1902
+ /* horizontal spine (close-set double line) */
1903
+ left: 4px; right: 0; top: 3px; height: 1.5px;
1904
+ box-shadow: 0 3px 0 var(--rake-color, #0d9488);
1905
+ }
1906
+ .sp-swatch-rake::after {
1907
+ /* vertical stem from top of swatch to spine */
1908
+ left: 4px; width: 1.5px; top: 0; height: 4px;
1909
+ }
1752
1910
  .sp-count { margin-left: auto; font-family: 'JetBrains Mono', monospace; font-size: 10px; }
1753
1911
 
1754
1912
  .sp-preview {