@glossarist/concept-browser 0.7.84 → 0.7.86

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.
@@ -26,6 +26,7 @@ import { useI18n } from '../i18n';
26
26
 
27
27
  const { t, locale } = useI18n();
28
28
  import type { Concept, Manifest, GraphEdge, PartitiveRelationWire } from '../adapters/types';
29
+ import { rakeStrokeStyle, type PartitiveMultiplicity } from '../utils/partitive-multiplicity';
29
30
  import { conceptUri } from '../adapters/model-bridge';
30
31
 
31
32
  const props = defineProps<{
@@ -537,11 +538,19 @@ function linkForce() {
537
538
  if (links.length === 0) return;
538
539
  const target = linkDistance;
539
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;
540
545
  for (const link of links) {
546
+ if (mutedT.has(link.type)) continue;
541
547
  const ai = idToIdx.get(link.source);
542
548
  const bi = idToIdx.get(link.target);
543
549
  if (ai === undefined || bi === undefined) continue;
544
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;
545
554
  const dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z;
546
555
  const d2 = dx*dx + dy*dy + dz*dz;
547
556
  if (d2 < 1e-6) continue;
@@ -752,38 +761,33 @@ function drawEdges(cx: number, cy: number) {
752
761
  }
753
762
 
754
763
  /**
755
- * Render PartitiveRelations as ISO 704 rake/bracket bundles in the sphere.
764
+ * Render PartitiveRelations as ISO 704:2022 rake brackets in the sphere.
756
765
  *
757
- * Each relation renders as a real bracket:
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.
758
769
  *
759
- * [comprehensive]
760
- * |
761
- * | ← single stem (vertical/radial)
762
- * |
763
- * +---•---+ ← spine midpoint (junction)
764
- * | | |
765
- * | | | ← N branches from spine midpoint
766
- * [p1] [p2] [p3] (one per partitive)
770
+ * Per-member multiplicity + is_delimiting is encoded on each DROP via
771
+ * rakeStrokeStyle() (see utils/partitive-multiplicity.ts):
767
772
  *
768
- * In 3D the bracket's "vertical" direction is along the line from
769
- * comprehensive to the centroid of partitives. The branches fan out
770
- * from the spine midpoint to each partitive. Right angles are not
771
- * enforceable in a force-directed layout; instead we use a single
772
- * shared junction point so the eye reads "ONE relation, N branches".
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
773
785
  *
774
- * ISO 704 line conventions applied (each segment: stem + spine + branches):
775
- * - default : single solid line
776
- * - plurality.isShared && !isUncertain : two parallel solid lines
777
- * (close-set; offset ±DOUBLE_GAP/2)
778
- * - plurality.isShared && isUncertain : one solid + one dashed line
786
+ * completeness === 'partial' : spine extends past last tooth (continued
787
+ * backline — "more exist but not shown")
779
788
  *
780
- * Per-member certainty:
781
- * - confirmed : branch is solid (with the plurality style above)
782
- * - possible : branch is dashed + reduced opacity (overrides plurality
783
- * on that branch only; the stem + spine keep the relation
784
- * style)
785
- *
786
- * A diamond marker at the comprehensive end signals the hyperedge origin.
789
+ * Diamond marker at the comprehensive end signals the hyperedge origin.
790
+ * Junction marker at the spine midpoint signals the right-angle corner.
787
791
  */
788
792
  function drawRakeBundles(
789
793
  svg: SVGSVGElement,
@@ -791,9 +795,13 @@ function drawRakeBundles(
791
795
  _cx: number,
792
796
  _cy: number,
793
797
  ) {
798
+ if (mutedTypes.value.has('__partitive__')) return;
794
799
  const color = uiStore.isDark ? '#2dd4bf' : '#0d9488';
795
800
  const DOUBLE_GAP = 4;
796
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'
797
805
 
798
806
  for (const rel of props.partitiveRelations ?? []) {
799
807
  const compParsed = UriRouter.parseUri(rel.comprehensive);
@@ -810,109 +818,124 @@ function drawRakeBundles(
810
818
  const p = pos.get(nid);
811
819
  return p ? { member, pos: p } : null;
812
820
  })
813
- .filter((x): x is { member: { uri: string; certainty: 'confirmed' | 'possible' }; pos: {x: number; y: number} } => x !== null);
821
+ .filter((x): x is { member: { uri: string; multiplicity: PartitiveMultiplicity; isDelimiting: boolean }; pos: {x: number; y: number} } => x !== null);
814
822
 
815
823
  if (memberPositions.length === 0) continue;
816
824
 
817
- // Spine midpoint = centroid of comprehensive + all partitives.
818
- // This is the junction where the stem meets the branches.
819
- const cx_part = (compPos.x + memberPositions.reduce((s, m) => s + m.pos.x, 0)) / (memberPositions.length + 1);
820
- const cy_part = (compPos.y + memberPositions.reduce((s, m) => s + m.pos.y, 0)) / (memberPositions.length + 1);
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.multiplicity, member.isDelimiting),
854
+ };
855
+ });
821
856
 
822
- const isShared = !!rel.plurality?.isShared;
823
- const isUncertain = !!rel.plurality?.isUncertain;
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;
824
870
 
825
871
  /**
826
- * Draw a single straight line segment with optional parallel offset
827
- * and dash style. Used for both stem and branches.
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().
828
877
  */
829
878
  const drawSegment = (
830
879
  ax: number, ay: number, bx: number, by: number,
831
- opts: { offset?: number; dashed?: boolean; opacity?: number } = {},
880
+ opts: { width: number; dashed?: boolean; offset?: number; opacity?: number },
832
881
  ) => {
882
+ const opacity = opts.opacity ?? 0.85;
833
883
  const offset = opts.offset ?? 0;
834
884
  const dashed = opts.dashed ?? false;
835
- const opacity = opts.opacity ?? 0.85;
836
- if (offset === 0) {
837
- const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
838
- line.setAttribute('class', 'rake-seg');
839
- line.setAttribute('x1', String(ax));
840
- line.setAttribute('y1', String(ay));
841
- line.setAttribute('x2', String(bx));
842
- line.setAttribute('y2', String(by));
843
- line.setAttribute('stroke', color);
844
- line.setAttribute('stroke-width', '1.6');
845
- line.setAttribute('opacity', String(opacity));
846
- line.setAttribute('stroke-linecap', 'round');
847
- if (dashed) line.setAttribute('stroke-dasharray', DASH);
848
- svg.appendChild(line);
849
- } else {
850
- // Compute perpendicular unit vector for parallel offset
851
- const dx = bx - ax, dy = by - ay;
852
- const len = Math.hypot(dx, dy) || 1;
853
- const px = -dy / len, py = dx / len;
854
- const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
855
- line.setAttribute('class', 'rake-seg');
856
- line.setAttribute('x1', String(ax + px * offset));
857
- line.setAttribute('y1', String(ay + py * offset));
858
- line.setAttribute('x2', String(bx + px * offset));
859
- line.setAttribute('y2', String(by + py * offset));
860
- line.setAttribute('stroke', color);
861
- line.setAttribute('stroke-width', '1.6');
862
- line.setAttribute('opacity', String(opacity));
863
- line.setAttribute('stroke-linecap', 'round');
864
- if (dashed) line.setAttribute('stroke-dasharray', DASH);
865
- svg.appendChild(line);
866
- }
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);
867
900
  };
868
901
 
869
- /**
870
- * Draw a segment with the relation's plurality style:
871
- * single solid (default), double solid (isShared),
872
- * or solid + dashed (isShared + isUncertain).
873
- */
874
- const drawSegmentWithPlurality = (
875
- ax: number, ay: number, bx: number, by: number,
876
- opts: { opacity?: number; perMemberPossible?: boolean } = {},
877
- ) => {
878
- const opacity = opts.opacity ?? 0.85;
879
- if (!isShared) {
880
- drawSegment(ax, ay, bx, by, { opacity, dashed: opts.perMemberPossible });
881
- } else {
882
- // Close-set double: two parallel lines offset ±DOUBLE_GAP/2
883
- drawSegment(ax, ay, bx, by, { offset: -DOUBLE_GAP / 2, opacity });
884
- drawSegment(ax, ay, bx, by, {
885
- offset: DOUBLE_GAP / 2,
886
- opacity,
887
- dashed: isUncertain || opts.perMemberPossible,
888
- });
889
- }
890
- };
902
+ // 5. Stem: comp → spine center (frame — single solid)
903
+ drawSegment(compPos.x, compPos.y, spineCx, spineCy, { width: FRAME_STROKE_WIDTH });
891
904
 
892
- // 1. Stem: comprehensive → spine midpoint
893
- drawSegmentWithPlurality(compPos.x, compPos.y, cx_part, cy_part);
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
+ }
894
909
 
895
- // 2. Branches: spine midpoint → each partitive
896
- for (const { member, pos: pPos } of memberPositions) {
897
- drawSegmentWithPlurality(cx_part, cy_part, pPos.x, pPos.y, {
898
- opacity: member.certainty === 'possible' ? 0.45 : 0.85,
899
- perMemberPossible: member.certainty === 'possible',
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,
900
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
+ }
901
924
  }
902
925
 
903
- // 3. Junction marker at spine midpoint (small filled circle)
926
+ // 8. Junction marker at spine center (the right-angle corner)
904
927
  const junction = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
905
928
  junction.setAttribute('class', 'rake-junction');
906
- junction.setAttribute('cx', String(cx_part));
907
- junction.setAttribute('cy', String(cy_part));
908
- junction.setAttribute('r', '3');
929
+ junction.setAttribute('cx', String(spineCx));
930
+ junction.setAttribute('cy', String(spineCy));
931
+ junction.setAttribute('r', '2.5');
909
932
  junction.setAttribute('fill', color);
910
- junction.setAttribute('opacity', '0.85');
933
+ junction.setAttribute('opacity', '0.9');
911
934
  junction.setAttribute('stroke', uiStore.isDark ? '#0a1f1c' : '#ffffff');
912
935
  junction.setAttribute('stroke-width', '1');
913
936
  svg.appendChild(junction);
914
937
 
915
- // 4. Diamond marker at comprehensive end (hyperedge origin signal)
938
+ // 9. Diamond marker at comprehensive end (hyperedge origin)
916
939
  const diamond = document.createElementNS('http://www.w3.org/2000/svg', 'path');
917
940
  const dsize = 5;
918
941
  diamond.setAttribute('d',
@@ -1249,6 +1272,8 @@ watch(() => [props.concept?.id, props.registerId] as const, ([newId, newReg]) =>
1249
1272
  /* ── Legend — grouped by relation TYPE (not category) ───── */
1250
1273
  const legendItems = computed(() => {
1251
1274
  graphVersion.value; /* reactive dependency */
1275
+ uiStore.isDark; /* re-evaluate when dark mode toggles (color depends on it) */
1276
+ mutedTypes.value; /* re-evaluate when mute set changes */
1252
1277
  const typeMap = new Map<string, { type: string; label: string; color: string; count: number }>();
1253
1278
  for (const link of links) {
1254
1279
  /* Per-type color (with override) so e.g. 'see' and 'references' differ */
@@ -1261,6 +1286,18 @@ const legendItems = computed(() => {
1261
1286
  }
1262
1287
  typeMap.get(key)!.count++;
1263
1288
  }
1289
+ /* Synthetic entry for PartitiveRelations (hyperedge rake bundles).
1290
+ These don't appear in `links` (they're rendered separately), so we
1291
+ inject a legend entry when any are present. */
1292
+ const partitiveCount = props.partitiveRelations?.length ?? 0;
1293
+ if (partitiveCount > 0) {
1294
+ typeMap.set('__partitive__', {
1295
+ type: '__partitive__',
1296
+ label: 'partitive relation',
1297
+ color: uiStore.isDark ? '#2dd4bf' : '#0d9488',
1298
+ count: partitiveCount,
1299
+ });
1300
+ }
1264
1301
  return Array.from(typeMap.values()).sort((a, b) => b.count - a.count);
1265
1302
  });
1266
1303
 
@@ -1285,6 +1322,9 @@ function toggleRegister(reg: string) {
1285
1322
  renderDOM();
1286
1323
  const c = canvasRef.value;
1287
1324
  if (c) drawEdges(c.clientWidth / 2, c.clientHeight * 0.46);
1325
+ /* Re-heat the simulation so nodes "feel" the freed/hidden edges and
1326
+ drift to a new equilibrium instead of staying frozen. */
1327
+ if (sim) sim.alpha(0.5).restart();
1288
1328
  }
1289
1329
 
1290
1330
  async function changeDegree(d: 1 | 2 | 3) {
@@ -1402,6 +1442,10 @@ function toggleType(type: string) {
1402
1442
  mutedTypes.value = n;
1403
1443
  const c = canvasRef.value;
1404
1444
  if (c) drawEdges(c.clientWidth / 2, c.clientHeight * 0.46);
1445
+ /* Re-heat the simulation so nodes drift to a new equilibrium with
1446
+ the freed/hidden edges. The link force reads mutedTypes each tick,
1447
+ so toggling effectively "removes" the edge from the force calc. */
1448
+ if (sim) sim.alpha(0.5).restart();
1405
1449
  }
1406
1450
 
1407
1451
  function isTypeMuted(type: string): boolean {
@@ -1574,9 +1618,12 @@ defineExpose({ navigate });
1574
1618
  <div class="sp-section-label">{{ t('sphere.relationTypes') }}</div>
1575
1619
  <div class="sp-legend-grid">
1576
1620
  <button v-for="item in legendItems" :key="item.type" class="sp-legend-item"
1577
- :class="{ muted: isTypeMuted(item.type) }" @click="toggleType(item.type)"
1621
+ :class="{ muted: isTypeMuted(item.type), 'is-partitive': item.type === '__partitive__' }"
1622
+ @click="toggleType(item.type)"
1578
1623
  :title="item.type">
1579
- <span class="sp-swatch" :style="{ background: isTypeMuted(item.type) ? '#b8b9cc' : item.color }"></span>
1624
+ <span v-if="item.type === '__partitive__'" class="sp-swatch sp-swatch-rake"
1625
+ :style="{ '--rake-color': isTypeMuted(item.type) ? '#b8b9cc' : item.color }"></span>
1626
+ <span v-else class="sp-swatch" :style="{ background: isTypeMuted(item.type) ? '#b8b9cc' : item.color }"></span>
1580
1627
  <span>{{ item.label }}</span>
1581
1628
  <span class="sp-count">{{ item.count }}</span>
1582
1629
  </button>
@@ -1825,6 +1872,26 @@ defineExpose({ navigate });
1825
1872
  .sp-legend-item:hover { background: var(--rule); }
1826
1873
  .sp-legend-item.muted { opacity: 0.35; }
1827
1874
  .sp-swatch { width: 18px; height: 2px; flex-shrink: 0; border-radius: 1px; }
1875
+ /* Rake swatch — small bracket icon signaling a PartitiveRelation hyperedge
1876
+ (not a single binary edge). Two parallel solid lines (close-set double)
1877
+ with a perpendicular stem, evoking the ISO 704 rake convention. */
1878
+ .sp-swatch-rake {
1879
+ width: 18px; height: 10px; position: relative; background: transparent !important;
1880
+ border-radius: 0;
1881
+ }
1882
+ .sp-swatch-rake::before,
1883
+ .sp-swatch-rake::after {
1884
+ content: ''; position: absolute; background: var(--rake-color, #0d9488);
1885
+ }
1886
+ .sp-swatch-rake::before {
1887
+ /* horizontal spine (close-set double line) */
1888
+ left: 4px; right: 0; top: 3px; height: 1.5px;
1889
+ box-shadow: 0 3px 0 var(--rake-color, #0d9488);
1890
+ }
1891
+ .sp-swatch-rake::after {
1892
+ /* vertical stem from top of swatch to spine */
1893
+ left: 4px; width: 1.5px; top: 0; height: 4px;
1894
+ }
1828
1895
  .sp-count { margin-left: auto; font-family: 'JetBrains Mono', monospace; font-size: 10px; }
1829
1896
 
1830
1897
  .sp-preview {
@@ -1,19 +1,18 @@
1
1
  import { computed, type ComputedRef } from 'vue';
2
2
  import type { Router } from 'vue-router';
3
+ // glossarist 0.4.24's top-level d.ts is incomplete for v3 model fields
4
+ // (multiplicity + is_delimiting). Import the v3-augmented types from
5
+ // glossarist/models (extended by src/adapters/non-verbal/glossarist-augment.d.ts).
6
+ import type { Concept, RelatedConcept, ConceptRef } from 'glossarist';
3
7
  import type {
4
- Concept,
5
- RelatedConcept,
6
8
  PartitiveRelation as GlsPartitiveRelation,
7
9
  PartitiveMember as GlsPartitiveMember,
8
- TypeSharedPlurality as GlsTypeSharedPlurality,
9
- ConceptRef,
10
- } from 'glossarist';
10
+ } from 'glossarist/models';
11
11
  import type {
12
12
  Manifest,
13
13
  GraphEdge,
14
14
  PartitiveRelationWire,
15
15
  PartitiveMemberWire,
16
- TypeSharedPluralityWire,
17
16
  } from '../adapters/types';
18
17
  import { getFactory } from '../adapters/factory';
19
18
  import { conceptUri } from '../adapters/model-bridge';
@@ -24,6 +23,7 @@ import { categorizeRelationship, relationshipLabel, INVERSE_RELATIONSHIPS } from
24
23
  import { langLabel } from '../utils/lang';
25
24
  import { escapeAttr } from '../utils/escape';
26
25
  import { useI18n } from '../i18n';
26
+ import type { PartitiveMultiplicity } from '../utils/partitive-multiplicity';
27
27
 
28
28
  export interface EdgeDisplay {
29
29
  uri: string;
@@ -190,7 +190,11 @@ export function useConceptEdges(
190
190
  .map((m: GlsPartitiveMember): PartitiveMemberWire | null => {
191
191
  const uri = resolveConceptRefUri(m.ref);
192
192
  if (!uri || uri === source) return null;
193
- return { uri, certainty: m.certainty };
193
+ return {
194
+ uri,
195
+ multiplicity: readMultiplicity(m),
196
+ isDelimiting: readIsDelimiting(m),
197
+ };
194
198
  })
195
199
  .filter((m: PartitiveMemberWire | null): m is PartitiveMemberWire => m !== null);
196
200
  if (partitives.length === 0) return null;
@@ -200,7 +204,6 @@ export function useConceptEdges(
200
204
  comprehensive,
201
205
  partitives,
202
206
  completeness: rel.completeness,
203
- plurality: projectPlurality(rel.plurality),
204
207
  criterion: rel.criterion ?? undefined,
205
208
  register: registerId.value,
206
209
  };
@@ -208,24 +211,21 @@ export function useConceptEdges(
208
211
  .filter((r: PartitiveRelationWire | null): r is PartitiveRelationWire => r !== null);
209
212
  });
210
213
 
211
- function projectPlurality(p: GlsTypeSharedPlurality | null): TypeSharedPluralityWire | null {
212
- if (!p) return null;
213
- const sharedTypeRef: ConceptRef | null = p.sharedType;
214
- const sharedType = sharedTypeRef
215
- ? [sharedTypeRef.source, sharedTypeRef.id].filter(Boolean).join(':') || null
216
- : null;
217
- return {
218
- isShared: p.isShared,
219
- isUncertain: p.isUncertain,
220
- sharedType,
221
- };
222
- }
223
-
224
214
  function resolveConceptRefUri(ref: ConceptRef | null): string | null {
225
215
  if (!ref) return null;
226
216
  return resolveRefUri({ source: ref.source ?? null, id: ref.id ?? null });
227
217
  }
228
218
 
219
+ // glossarist 0.4.24 carries multiplicity + is_delimiting natively on
220
+ // PartitiveMember. Read them directly — no migration adapter needed.
221
+ function readMultiplicity(m: GlsPartitiveMember): PartitiveMultiplicity {
222
+ return m.multiplicity;
223
+ }
224
+
225
+ function readIsDelimiting(m: GlsPartitiveMember): boolean {
226
+ return m.is_delimiting;
227
+ }
228
+
229
229
  function resolveRefUri(ref: { source: string | null; id: string | null } | null): string | null {
230
230
  const target = resolveRelatedRef(ref);
231
231
  if (!target) return null;
@@ -0,0 +1,106 @@
1
+ /**
2
+ * ISO 704:2022 §5.5.4.2 — partitive multiplicity + delimiting model.
3
+ *
4
+ * Multiplicity (5 values) encodes part-count semantics:
5
+ * - compulsory : exactly one, required
6
+ * - optional : zero or one
7
+ * - compulsory_multiple : two or more, required
8
+ * - optional_multiple : zero or more
9
+ * - compulsory_at_least_one : one or more (required, count unknown)
10
+ *
11
+ * Delimiting (boolean) is orthogonal — a delimiting part behaves like a
12
+ * delimiting characteristic in generic relations: it distinguishes the
13
+ * comprehensive concept from coordinate concepts. Visual encoding:
14
+ * 3× stroke width (4.5 px vs 1.5 px).
15
+ *
16
+ * Adding a new multiplicity value = add one entry to PARTITIVE_MULTIPLICITY.
17
+ * Renderers consume RakeStrokeStyle via rakeStrokeStyle(); never inspect
18
+ * the raw multiplicity string. OCP + MECE + DRY.
19
+ */
20
+
21
+ export const PARTITIVE_MULTIPLICITY = {
22
+ compulsory: { lines: 1, pattern: 'solid', label: 'Compulsory' },
23
+ optional: { lines: 1, pattern: 'dashed', label: 'Optional' },
24
+ compulsory_multiple: { lines: 2, pattern: 'solid', label: 'Compulsory multiple' },
25
+ optional_multiple: { lines: 2, pattern: 'dashed', label: 'Optional multiple' },
26
+ compulsory_at_least_one: { lines: 2, pattern: 'mixed', label: 'At least one' },
27
+ } as const;
28
+
29
+ export type PartitiveMultiplicity = keyof typeof PARTITIVE_MULTIPLICITY;
30
+
31
+ export const PARTITIVE_MULTIPLICITY_VALUES: readonly PartitiveMultiplicity[] =
32
+ Object.keys(PARTITIVE_MULTIPLICITY) as PartitiveMultiplicity[];
33
+
34
+ export function isPartitiveMultiplicity(value: unknown): value is PartitiveMultiplicity {
35
+ return typeof value === 'string' && value in PARTITIVE_MULTIPLICITY;
36
+ }
37
+
38
+ export type LinePattern = 'solid' | 'dashed' | 'mixed';
39
+
40
+ export interface MultiplicityDefinition {
41
+ /** Number of parallel lines used to render the multiplicity. */
42
+ readonly lines: 1 | 2;
43
+ /** Dash pattern: solid, dashed, or mixed (one solid + one dashed). */
44
+ readonly pattern: LinePattern;
45
+ /** Human-readable label for legends and badges. */
46
+ readonly label: string;
47
+ }
48
+
49
+ export function multiplicityDefinition(m: PartitiveMultiplicity): MultiplicityDefinition {
50
+ return PARTITIVE_MULTIPLICITY[m];
51
+ }
52
+
53
+ // ── RakeStrokeStyle — the render-ready form consumed by all renderers ────
54
+
55
+ export interface RakeStrokeStyle {
56
+ /** 1 or 2 parallel lines. */
57
+ readonly lineCount: 1 | 2;
58
+ /** Primary (always-present) line dash. */
59
+ readonly primaryDashed: boolean;
60
+ /** Secondary line dash (only meaningful when lineCount === 2). */
61
+ readonly secondaryDashed: boolean;
62
+ /** Stroke width in px. 1.5 normal, 4.5 (3×) when is_delimiting. */
63
+ readonly strokeWidth: number;
64
+ }
65
+
66
+ export const NORMAL_STROKE_WIDTH = 1.5;
67
+ export const DELIMITING_STROKE_WIDTH = 4.5; // 3× normal per ISO 704
68
+
69
+ /**
70
+ * Compute the render-ready stroke style for a partitive member.
71
+ *
72
+ * Pure function — same inputs always yield the same RakeStrokeStyle.
73
+ * Consumed by both the sidebar rake diagram (PartitiveRelationDiagram)
74
+ * and the sphere rake bundles (RelationSphere.drawRakeBundles).
75
+ */
76
+ export function rakeStrokeStyle(
77
+ multiplicity: PartitiveMultiplicity,
78
+ isDelimiting: boolean,
79
+ ): RakeStrokeStyle {
80
+ const def = PARTITIVE_MULTIPLICITY[multiplicity];
81
+ return {
82
+ lineCount: def.lines,
83
+ primaryDashed: def.pattern === 'dashed',
84
+ secondaryDashed: def.pattern === 'dashed' || def.pattern === 'mixed',
85
+ strokeWidth: isDelimiting ? DELIMITING_STROKE_WIDTH : NORMAL_STROKE_WIDTH,
86
+ };
87
+ }
88
+
89
+ // ── Migration from glossarist 0.4.20 certainty → ISO 704 multiplicity ────
90
+
91
+ /**
92
+ * glossarist 0.4.20 carries per-member `certainty: 'confirmed' | 'possible'`
93
+ * (a v0.4 abstraction that conflated "this member exists" with "we're sure
94
+ * about it"). ISO 704:2022 replaces that with the multiplicity enum.
95
+ *
96
+ * Until glossarist publishes native multiplicity support, this helper maps
97
+ * the legacy field to the closest ISO 704 multiplicity:
98
+ * - confirmed → compulsory
99
+ * - possible → optional
100
+ *
101
+ * Once glossarist publishes multiplicity natively, the bridge can read
102
+ * it directly and this helper can be deleted.
103
+ */
104
+ export function multiplicityFromCertainty(certainty: 'confirmed' | 'possible' | null | undefined): PartitiveMultiplicity {
105
+ return certainty === 'possible' ? 'optional' : 'compulsory';
106
+ }