@glossarist/concept-browser 0.7.85 → 0.7.87

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.
@@ -25,6 +25,10 @@ import {
25
25
  DATE_TYPES,
26
26
  ConceptRef,
27
27
  } from 'glossarist';
28
+ import {
29
+ isPartitivePresence,
30
+ isPartitiveCount,
31
+ } from '../utils/partitive-multiplicity';
28
32
  import {
29
33
  LetterSymbol,
30
34
  GrammarInfo,
@@ -166,20 +170,22 @@ interface JsonLdLocalizedConcept {
166
170
 
167
171
  interface JsonLdPartitiveMember {
168
172
  'gl:ref'?: JsonLdRef;
173
+ /** ISO 704:2022 MECE: presence (required | optional) */
174
+ 'gl:presence'?: string;
175
+ /** ISO 704:2022 MECE: count (exactly_one | at_least_one | multiple) */
176
+ 'gl:count'?: string;
177
+ /** ISO 704:2022: delimiting part (3× stroke width in diagrams) */
178
+ 'gl:isDelimiting'?: boolean;
179
+ /** Legacy v3 field — one-string multiplicity, split into presence+count */
180
+ 'gl:multiplicity'?: string;
181
+ /** Legacy v2 field — migrated to presence+count */
169
182
  'gl:certainty'?: string;
170
183
  }
171
184
 
172
- interface JsonLdTypeSharedPlurality {
173
- 'gl:isShared'?: boolean;
174
- 'gl:isUncertain'?: boolean;
175
- 'gl:sharedType'?: JsonLdRef;
176
- }
177
-
178
185
  interface JsonLdPartitiveRelation {
179
186
  'gl:comprehensive'?: JsonLdRef;
180
187
  'gl:hasPartitive'?: JsonLdPartitiveMember[];
181
188
  'gl:completeness'?: string;
182
- 'gl:hasPlurality'?: JsonLdTypeSharedPlurality;
183
189
  'gl:criterion'?: Record<string, string> | string;
184
190
  }
185
191
 
@@ -592,7 +598,23 @@ function mapPartitiveRelationFromJsonLd(r: JsonLdPartitiveRelation): Record<stri
592
598
  const ref = m['gl:ref'] ? mapRefFromJsonLd(m['gl:ref']) : null;
593
599
  if (!ref) return null;
594
600
  const out: Record<string, unknown> = { ref };
595
- if (m['gl:certainty']) out.certainty = m['gl:certainty'];
601
+ // ISO 704:2022 MECE: prefer presence × count from JSON-LD. Fall back
602
+ // to migrating the legacy one-string `multiplicity` or v2 `certainty`
603
+ // so data in transit from older glossarist versions still loads.
604
+ const presence = m['gl:presence'];
605
+ const count = m['gl:count'];
606
+ if (isPartitivePresence(presence) && isPartitiveCount(count)) {
607
+ out.presence = presence;
608
+ out.count = count;
609
+ } else {
610
+ const raw = m['gl:multiplicity'] ?? splitLegacyCertainty(m['gl:certainty']);
611
+ if (raw) {
612
+ const parts = splitMultiplicity(raw);
613
+ out.presence = parts.presence;
614
+ out.count = parts.count;
615
+ }
616
+ }
617
+ if (m['gl:isDelimiting'] === true) out.is_delimiting = true;
596
618
  return out;
597
619
  })
598
620
  .filter((m): m is Record<string, unknown> => m !== null);
@@ -608,17 +630,6 @@ function mapPartitiveRelationFromJsonLd(r: JsonLdPartitiveRelation): Record<stri
608
630
 
609
631
  if (r['gl:completeness']) out.completeness = r['gl:completeness'];
610
632
 
611
- const plurality = r['gl:hasPlurality'];
612
- if (plurality && typeof plurality['gl:isShared'] === 'boolean') {
613
- const p: Record<string, unknown> = { isShared: plurality['gl:isShared'] };
614
- if (typeof plurality['gl:isUncertain'] === 'boolean') {
615
- p.isUncertain = plurality['gl:isUncertain'];
616
- }
617
- const sharedType = plurality['gl:sharedType'] ? mapRefFromJsonLd(plurality['gl:sharedType']) : null;
618
- if (sharedType) p.sharedType = sharedType;
619
- out.plurality = p;
620
- }
621
-
622
633
  if (r['gl:criterion']) {
623
634
  out.criterion = typeof r['gl:criterion'] === 'string'
624
635
  ? { default: r['gl:criterion'] }
@@ -628,6 +639,24 @@ function mapPartitiveRelationFromJsonLd(r: JsonLdPartitiveRelation): Record<stri
628
639
  return out;
629
640
  }
630
641
 
642
+ /** Migrate legacy v2 certainty → old v3 multiplicity string. */
643
+ function splitLegacyCertainty(certainty: string | undefined): string | null {
644
+ if (!certainty) return null;
645
+ return certainty === 'possible' ? 'optional' : 'compulsory';
646
+ }
647
+
648
+ /** Split the legacy one-string multiplicity into MECE presence × count. */
649
+ function splitMultiplicity(m: string): { presence: 'required' | 'optional'; count: 'exactly_one' | 'at_least_one' | 'multiple' } {
650
+ const LEGACY_MAP: Record<string, { presence: 'required' | 'optional'; count: 'exactly_one' | 'at_least_one' | 'multiple' }> = {
651
+ compulsory: { presence: 'required', count: 'exactly_one' },
652
+ optional: { presence: 'optional', count: 'exactly_one' },
653
+ compulsory_multiple: { presence: 'required', count: 'multiple' },
654
+ optional_multiple: { presence: 'optional', count: 'multiple' },
655
+ compulsory_at_least_one: { presence: 'required', count: 'at_least_one' },
656
+ };
657
+ return LEGACY_MAP[m] ?? { presence: 'required', count: 'exactly_one' };
658
+ }
659
+
631
660
  function conceptFromJsonLd(doc: JsonLdConcept): Concept {
632
661
  const id = String(doc['gl:identifier'] ?? doc['@id']?.split('/').pop() ?? '');
633
662
  const localizations: Record<string, unknown> = {};
@@ -1,4 +1,4 @@
1
- // Local module augmentation for glossarist 0.4.2.
1
+ // Local module augmentation for glossarist 0.4.26.
2
2
  //
3
3
  // Upstream's published src/models/index.d.ts declares ZERO classes for the
4
4
  // non-verbal hierarchy (Figure, Table, Formula, FigureImage, NonVerbalEntity,
@@ -9,14 +9,15 @@
9
9
  //
10
10
  // This file declares the runtime shape so consumer code can be type-checked.
11
11
  // DELETE this file when upstream ships proper declarations — tracked by
12
- // PR glossarist/glossarist-js#31 (targets v0.4.3+).
12
+ // PR glossarist/glossarist-js#31.
13
13
  //
14
- // v2 PartitiveRelation block (below) tracks glossarist 0.4.20 — the runtime
15
- // exports PartitiveRelation/PartitiveMember/TypeSharedPlurality from
16
- // src/models/index.js, but the d.ts has not been updated. Remove the v2
17
- // block when upstream PRs the d.ts changes.
14
+ // v2 PartitiveRelation block (below) tracks glossarist 0.4.26 — the runtime
15
+ // exports PartitiveRelation/PartitiveMember from src/models/index.js, but
16
+ // the d.ts has not been updated. Remove the v2 block when upstream PRs the
17
+ // d.ts changes.
18
18
 
19
19
  import type { ConceptSource, Citation, GlossaristModel } from 'glossarist';
20
+ import type { PartitivePresence, PartitiveCount } from '../../utils/partitive-multiplicity';
20
21
 
21
22
  declare module 'glossarist' {
22
23
  class RegistrableModel extends GlossaristModel {
@@ -136,8 +137,9 @@ declare module 'glossarist' {
136
137
  static fromJSON(data: Record<string, unknown>): BibliographyData;
137
138
  }
138
139
 
139
- // ── v2 PartitiveRelation (glossarist 0.4.20) ────────────────────────────
140
+ // ── PartitiveRelation (glossarist 0.4.26 — MECE presence × count) ─
140
141
  // Runtime exports these from src/models/index.js; d.ts is stale.
142
+ // (Tracked by PR glossarist/glossarist-js#31.)
141
143
 
142
144
  type Completeness = 'complete' | 'partial';
143
145
  const COMPLETENESS: { readonly COMPLETE: 'complete'; readonly PARTIAL: 'partial' };
@@ -145,52 +147,63 @@ declare module 'glossarist' {
145
147
  const DEFAULT_COMPLETENESS: Completeness;
146
148
  function isValidCompleteness(value: unknown): value is Completeness;
147
149
 
148
- type MemberCertainty = 'confirmed' | 'possible';
149
- const MEMBER_CERTAINTY: { readonly CONFIRMED: 'confirmed'; readonly POSSIBLE: 'possible' };
150
- const MEMBER_CERTAINTY_VALUES: readonly MemberCertainty[];
151
- const DEFAULT_MEMBER_CERTAINTY: MemberCertainty;
152
- function isValidMemberCertainty(value: unknown): value is MemberCertainty;
150
+ const PARTITIVE_PRESENCE: { readonly REQUIRED: 'required'; readonly OPTIONAL: 'optional' } & { readonly VALUES: readonly ['required', 'optional'] };
151
+ type PartitivePresence = (typeof PARTITIVE_PRESENCE.VALUES)[number];
152
+ const PARTITIVE_PRESENCE_VALUES: readonly PartitivePresence[];
153
+ const DEFAULT_PRESENCE: PartitivePresence;
154
+ function isValidPresence(value: unknown): value is PartitivePresence;
153
155
 
154
- class TypeSharedPlurality extends GlossaristModel {
155
- constructor(data?: {
156
- isShared?: boolean;
157
- is_shared?: boolean;
158
- isUncertain?: boolean;
159
- is_uncertain?: boolean;
160
- sharedType?: ConceptRef;
161
- shared_type?: ConceptRef;
162
- });
163
- readonly isShared: boolean;
164
- readonly isUncertain: boolean;
165
- readonly sharedType: ConceptRef | null;
166
- hasSharedType(): boolean;
167
- toJSON(): { is_shared: boolean; is_uncertain?: boolean; shared_type?: ReturnType<ConceptRef['toJSON']> };
168
- static fromJSON(data: Record<string, unknown>): TypeSharedPlurality;
156
+ const PARTITIVE_COUNT: { readonly EXACTLY_ONE: 'exactly_one'; readonly AT_LEAST_ONE: 'at_least_one'; readonly MULTIPLE: 'multiple' } & { readonly VALUES: readonly ['exactly_one', 'at_least_one', 'multiple'] };
157
+ type PartitiveCount = (typeof PARTITIVE_COUNT.VALUES)[number];
158
+ const PARTITIVE_COUNT_VALUES: readonly PartitiveCount[];
159
+ const DEFAULT_COUNT: PartitiveCount;
160
+ function isValidCount(value: unknown): value is PartitiveCount;
161
+
162
+ type Multiplicity =
163
+ | 'compulsory' | 'optional' | 'compulsory_multiple'
164
+ | 'optional_multiple' | 'compulsory_at_least_one';
165
+ const MULTIPLICITY: Record<string, Multiplicity>;
166
+ const MULTIPLICITY_VALUES: readonly Multiplicity[];
167
+ const DEFAULT_MULTIPLICITY: Multiplicity;
168
+ function isValidMultiplicity(value: unknown): value is Multiplicity;
169
+ function multiplicityFromPair(presence: PartitivePresence, count: PartitiveCount): Multiplicity;
170
+ function pairFromMultiplicity(name: Multiplicity): { presence: PartitivePresence; count: PartitiveCount };
171
+
172
+ interface Concept {
173
+ readonly partitiveRelations: PartitiveRelation[];
169
174
  }
170
175
 
171
- class PartitiveMember extends GlossaristModel {
172
- constructor(data?: { ref?: ConceptRef; certainty?: MemberCertainty });
176
+ // Re-exposed here so callers importing from the top-level `glossarist`
177
+ // entry get the MECE-augmented shape, not the stale d.ts type.
178
+ export class PartitiveMember extends GlossaristModel {
179
+ constructor(data?: {
180
+ ref?: ConceptRef;
181
+ presence?: PartitivePresence;
182
+ count?: PartitiveCount;
183
+ is_delimiting?: boolean;
184
+ });
173
185
  readonly ref: ConceptRef;
174
- readonly certainty: MemberCertainty;
175
- readonly isConfirmed: boolean;
176
- readonly isPossible: boolean;
177
- toJSON(): { ref: ReturnType<ConceptRef['toJSON']>; certainty?: MemberCertainty };
186
+ readonly presence: PartitivePresence;
187
+ readonly count: PartitiveCount;
188
+ readonly is_delimiting: boolean;
189
+ readonly isRequired: boolean;
190
+ readonly isOptional: boolean;
191
+ get isDelimiting(): boolean;
192
+ toJSON(): { ref: ReturnType<ConceptRef['toJSON']>; presence?: PartitivePresence; count?: PartitiveCount; is_delimiting?: boolean };
178
193
  static fromJSON(data: Record<string, unknown>): PartitiveMember;
179
194
  static identityOf(value: unknown): string;
180
195
  }
181
196
 
182
- class PartitiveRelation extends GlossaristModel {
197
+ export class PartitiveRelation extends GlossaristModel {
183
198
  constructor(data?: {
184
199
  comprehensive?: ConceptRef;
185
200
  partitives?: PartitiveMember[];
186
201
  completeness?: Completeness;
187
- plurality?: TypeSharedPlurality;
188
202
  criterion?: Record<string, string> | string;
189
203
  });
190
204
  readonly comprehensive: ConceptRef;
191
205
  readonly partitives: PartitiveMember[];
192
206
  readonly completeness: Completeness;
193
- readonly plurality: TypeSharedPlurality | null;
194
207
  readonly criterion: Record<string, string> | null;
195
208
  readonly isComplete: boolean;
196
209
  readonly isPartial: boolean;
@@ -201,16 +214,11 @@ declare module 'glossarist' {
201
214
  comprehensive: ReturnType<ConceptRef['toJSON']>;
202
215
  partitives: ReturnType<PartitiveMember['toJSON']>[];
203
216
  completeness: Completeness;
204
- plurality?: ReturnType<TypeSharedPlurality['toJSON']>;
205
217
  criterion?: Record<string, string>;
206
218
  };
207
219
  static fromJSON(data: Record<string, unknown>): PartitiveRelation;
208
220
  static identityOf(value: unknown): string;
209
221
  }
210
-
211
- interface Concept {
212
- readonly partitiveRelations: PartitiveRelation[];
213
- }
214
222
  }
215
223
 
216
224
  // Re-declare the same exports under the 'glossarist/models' subpath so
@@ -223,11 +231,27 @@ declare module 'glossarist/models' {
223
231
  export const DEFAULT_COMPLETENESS: Completeness;
224
232
  export function isValidCompleteness(value: unknown): value is Completeness;
225
233
 
226
- export type MemberCertainty = 'confirmed' | 'possible';
227
- export const MEMBER_CERTAINTY: { readonly CONFIRMED: 'confirmed'; readonly POSSIBLE: 'possible' };
228
- export const MEMBER_CERTAINTY_VALUES: readonly MemberCertainty[];
229
- export const DEFAULT_MEMBER_CERTAINTY: MemberCertainty;
230
- export function isValidMemberCertainty(value: unknown): value is MemberCertainty;
234
+ export const PARTITIVE_PRESENCE: { readonly REQUIRED: 'required'; readonly OPTIONAL: 'optional' } & { readonly VALUES: readonly ['required', 'optional'] };
235
+ export type PartitivePresence = (typeof PARTITIVE_PRESENCE.VALUES)[number];
236
+ export const PARTITIVE_PRESENCE_VALUES: readonly PartitivePresence[];
237
+ export const DEFAULT_PRESENCE: PartitivePresence;
238
+ export function isValidPresence(value: unknown): value is PartitivePresence;
239
+
240
+ export const PARTITIVE_COUNT: { readonly EXACTLY_ONE: 'exactly_one'; readonly AT_LEAST_ONE: 'at_least_one'; readonly MULTIPLE: 'multiple' } & { readonly VALUES: readonly ['exactly_one', 'at_least_one', 'multiple'] };
241
+ export type PartitiveCount = (typeof PARTITIVE_COUNT.VALUES)[number];
242
+ export const PARTITIVE_COUNT_VALUES: readonly PartitiveCount[];
243
+ export const DEFAULT_COUNT: PartitiveCount;
244
+ export function isValidCount(value: unknown): value is PartitiveCount;
245
+
246
+ export type Multiplicity =
247
+ | 'compulsory' | 'optional' | 'compulsory_multiple'
248
+ | 'optional_multiple' | 'compulsory_at_least_one';
249
+ export const MULTIPLICITY: Record<string, Multiplicity>;
250
+ export const MULTIPLICITY_VALUES: readonly Multiplicity[];
251
+ export const DEFAULT_MULTIPLICITY: Multiplicity;
252
+ export function isValidMultiplicity(value: unknown): value is Multiplicity;
253
+ export function multiplicityFromPair(presence: PartitivePresence, count: PartitiveCount): Multiplicity;
254
+ export function pairFromMultiplicity(name: Multiplicity): { presence: PartitivePresence; count: PartitiveCount };
231
255
 
232
256
  export class TypeSharedPlurality extends GlossaristModel {
233
257
  constructor(data?: {
@@ -246,13 +270,23 @@ declare module 'glossarist/models' {
246
270
  static fromJSON(data: Record<string, unknown>): TypeSharedPlurality;
247
271
  }
248
272
 
273
+ // ── PartitiveRelation (glossarist 0.4.26 — MECE presence × count) ─
274
+
249
275
  export class PartitiveMember extends GlossaristModel {
250
- constructor(data?: { ref?: ConceptRef; certainty?: MemberCertainty });
276
+ constructor(data?: {
277
+ ref?: ConceptRef;
278
+ presence?: PartitivePresence;
279
+ count?: PartitiveCount;
280
+ is_delimiting?: boolean;
281
+ });
251
282
  readonly ref: ConceptRef;
252
- readonly certainty: MemberCertainty;
253
- readonly isConfirmed: boolean;
254
- readonly isPossible: boolean;
255
- toJSON(): { ref: ReturnType<ConceptRef['toJSON']>; certainty?: MemberCertainty };
283
+ readonly presence: PartitivePresence;
284
+ readonly count: PartitiveCount;
285
+ readonly is_delimiting: boolean;
286
+ readonly isRequired: boolean;
287
+ readonly isOptional: boolean;
288
+ get isDelimiting(): boolean;
289
+ toJSON(): { ref: ReturnType<ConceptRef['toJSON']>; presence?: PartitivePresence; count?: PartitiveCount; is_delimiting?: boolean };
256
290
  static fromJSON(data: Record<string, unknown>): PartitiveMember;
257
291
  static identityOf(value: unknown): string;
258
292
  }
@@ -262,13 +296,11 @@ declare module 'glossarist/models' {
262
296
  comprehensive?: ConceptRef;
263
297
  partitives?: PartitiveMember[];
264
298
  completeness?: Completeness;
265
- plurality?: TypeSharedPlurality;
266
299
  criterion?: Record<string, string> | string;
267
300
  });
268
301
  readonly comprehensive: ConceptRef;
269
302
  readonly partitives: PartitiveMember[];
270
303
  readonly completeness: Completeness;
271
- readonly plurality: TypeSharedPlurality | null;
272
304
  readonly criterion: Record<string, string> | null;
273
305
  readonly isComplete: boolean;
274
306
  readonly isPartial: boolean;
@@ -279,7 +311,6 @@ declare module 'glossarist/models' {
279
311
  comprehensive: ReturnType<ConceptRef['toJSON']>;
280
312
  partitives: ReturnType<PartitiveMember['toJSON']>[];
281
313
  completeness: Completeness;
282
- plurality?: ReturnType<TypeSharedPlurality['toJSON']>;
283
314
  criterion?: Record<string, string>;
284
315
  };
285
316
  static fromJSON(data: Record<string, unknown>): PartitiveRelation;
@@ -168,20 +168,15 @@ export interface PartitiveRelationWire {
168
168
  comprehensive: string;
169
169
  partitives: PartitiveMemberWire[];
170
170
  completeness: 'complete' | 'partial';
171
- plurality: TypeSharedPluralityWire | null;
172
171
  criterion?: Record<string, string>;
173
172
  register: string;
174
173
  }
175
174
 
176
175
  export interface PartitiveMemberWire {
177
176
  uri: string;
178
- certainty: 'confirmed' | 'possible';
179
- }
180
-
181
- export interface TypeSharedPluralityWire {
182
- isShared: boolean;
183
- isUncertain: boolean;
184
- sharedType?: string | null;
177
+ presence: 'required' | 'optional';
178
+ count: 'exactly_one' | 'at_least_one' | 'multiple';
179
+ isDelimiting: boolean;
185
180
  }
186
181
 
187
182
  export interface GraphNode {
@@ -1,46 +1,65 @@
1
1
  <script setup lang="ts">
2
2
  /**
3
3
  * PartitiveRelationDiagram — renders a single PartitiveRelation as an
4
- * ISO 704 rake/bracket diagram in SVG.
4
+ * ISO 704:2022 rake/bracket diagram in SVG.
5
5
  *
6
- * Layout (looking top-down):
6
+ * Layout:
7
7
  *
8
8
  * [comprehensive]
9
9
  * │
10
- * │ ← vertical stem
11
- * ┌───────────────┴───────────────┐ ← horizontal spine
10
+ * │ ← stem (always single solid — relation frame)
11
+ * ┌───────────────┴───────────────┐ ← spine (always single solid — relation frame)
12
12
  * │ │ │
13
- * │ │ │ ← perpendicular drops
13
+ * │ │ │ ← drops (per-member multiplicity + delimiting)
14
14
  * [part1] [part2] [part3]
15
15
  *
16
- * Line conventions per ISO 704:
17
- * - default : single solid line
18
- * - plurality.isShared && !isUncertain : close-set DOUBLE solid lines
19
- * on stem + spine (drops stay single)
20
- * - plurality.isShared && isUncertain : one solid + one DASHED line
21
- * - completeness === 'partial' : spine extends past the last
22
- * tooth (continued backline)
16
+ * Per ISO 704:2022, each drop's line style encodes its multiplicity +
17
+ * is_delimiting (see utils/partitive-multiplicity.ts for the registry):
18
+ *
19
+ * multiplicity is_delimiting visual
20
+ * ------------------- -------------- --------------------------------
21
+ * compulsory false 1 solid @ 1.5px
22
+ * compulsory true 1 solid @ 4.5px (bold, 3× width)
23
+ * optional false 1 dashed @ 1.5px
24
+ * optional true 1 dashed @ 4.5px
25
+ * compulsory_multiple false 2 solid @ 1.5px
26
+ * compulsory_multiple true 2 solid @ 4.5px
27
+ * optional_multiple false 2 dashed @ 1.5px
28
+ * optional_multiple true 2 dashed @ 4.5px
29
+ * compulsory_at_least_one false 1 solid + 1 dashed @ 1.5px
30
+ * compulsory_at_least_one true 1 solid + 1 dashed @ 4.5px
31
+ *
32
+ * completeness === 'partial' : spine extends past the last tooth
33
+ * (continued backline — "more exist but
34
+ * not shown")
23
35
  *
24
36
  * Node sizing is designation-aware: the longest label drives the node
25
37
  * width (clamped to [MIN_NODE_W, MAX_NODE_W]); labels that don't fit
26
- * wrap to a second line via tspan. This lets the rake show actual
27
- * designations ('system of quantities') instead of bare IDs ('1.3').
38
+ * wrap to a second line via tspan.
28
39
  */
29
40
  import { computed } from 'vue';
30
- import type { TypeSharedPluralityWire } from '../adapters/types';
41
+ import {
42
+ rakeStrokeStyle,
43
+ type PartitivePresence,
44
+ type PartitiveCount,
45
+ type RakeStrokeStyle,
46
+ } from '../utils/partitive-multiplicity';
31
47
 
32
48
  export interface PartitiveMemberLabeled {
33
49
  uri: string;
34
50
  label: string;
35
- certainty: 'confirmed' | 'possible';
51
+ presence: PartitivePresence;
52
+ count: PartitiveCount;
53
+ isDelimiting: boolean;
36
54
  }
37
55
 
38
56
  const props = withDefaults(defineProps<{
39
57
  comprehensiveLabel: string;
40
58
  partitives: PartitiveMemberLabeled[];
41
59
  completeness: 'complete' | 'partial';
42
- plurality: TypeSharedPluralityWire | null;
43
60
  criterion?: string | null;
61
+ /** Optional per-member tooltip string. */
62
+ memberTooltip?: (m: { presence: PartitivePresence; count: PartitiveCount; isDelimiting: boolean }) => string;
44
63
  }>(), {
45
64
  criterion: null,
46
65
  });
@@ -59,29 +78,19 @@ const SPINE_TO_NODE = 32;
59
78
  const GAP_X = 16;
60
79
  const PADDING_X = 20;
61
80
  const PADDING_Y = 14;
62
- const DOUBLE_GAP = 3;
81
+ const DOUBLE_GAP = 3; // gap between the two lines of a close-set double
63
82
  const PARTIAL_TAIL = 32;
64
83
  const DASH_PATTERN = '4 3';
65
- const CHAR_W = 6.2; // approx for 11px sans-serif
66
- const NODE_TEXT_PAD = 16; // horizontal padding inside node rect
67
-
68
- type LineVariant = 'single' | 'double-solid' | 'solid-plus-dashed';
69
-
70
- const lineVariant = computed<LineVariant>(() => {
71
- if (!props.plurality?.isShared) return 'single';
72
- return props.plurality.isUncertain ? 'solid-plus-dashed' : 'double-solid';
73
- });
84
+ const CHAR_W = 6.2; // approx for 11px sans-serif
85
+ const NODE_TEXT_PAD = 16;
86
+ const FRAME_STROKE_WIDTH = 1.4; // stem + spine use this width always
74
87
 
75
88
  /** Estimated pixel width of a string at 11px sans-serif. */
76
89
  function estimateTextWidth(text: string): number {
77
90
  return text.length * CHAR_W;
78
91
  }
79
92
 
80
- /**
81
- * Wrap a label to at most 2 lines, breaking on word boundaries.
82
- * If a single word exceeds the limit, it stays on its own line and
83
- * gets truncated with an ellipsis.
84
- */
93
+ /** Wrap a label to at most 2 lines, breaking on word boundaries. */
85
94
  function wrapLabel(text: string, maxCharsPerLine: number): string[] {
86
95
  if (text.length <= maxCharsPerLine) return [text];
87
96
  const words = text.split(/\s+/);
@@ -89,7 +98,6 @@ function wrapLabel(text: string, maxCharsPerLine: number): string[] {
89
98
  let current = '';
90
99
  for (const w of words) {
91
100
  if (w.length > maxCharsPerLine) {
92
- // word itself too long — flush current, push truncated word
93
101
  if (current) { lines.push(current); current = ''; }
94
102
  lines.push(w.slice(0, Math.max(1, maxCharsPerLine - 1)) + '…');
95
103
  continue;
@@ -132,7 +140,12 @@ const layout = computed(() => {
132
140
  const partitiveSlots = props.partitives.map((member, i) => {
133
141
  const x = PADDING_X + nodeW / 2 + i * (nodeW + GAP_X);
134
142
  const labelLines = wrapped[i + 1]; // index 0 is comprehensive
135
- return { member, x, labelLines };
143
+ return {
144
+ member,
145
+ x,
146
+ labelLines,
147
+ style: rakeStrokeStyle(member.presence, member.count, member.isDelimiting),
148
+ };
136
149
  });
137
150
 
138
151
  const leftX = partitiveSlots[0]?.x ?? centerX;
@@ -157,19 +170,38 @@ const layout = computed(() => {
157
170
  };
158
171
  });
159
172
 
160
- function strokeAttr(variant: LineVariant, opacity = 1, override?: 'dash') {
161
- const useDash = override === 'dash' || variant === 'solid-plus-dashed';
173
+ /**
174
+ * Build SVG stroke attributes for the relation frame (stem + spine).
175
+ *
176
+ * The frame is structural — always a single solid line at standard
177
+ * width. Per-member multiplicity + delimiting are encoded on the drops
178
+ * only (MECE: frame vs drops).
179
+ */
180
+ const frameStrokeAttrs = computed(() => ({
181
+ stroke: 'currentColor',
182
+ 'stroke-width': FRAME_STROKE_WIDTH,
183
+ }));
184
+
185
+ /**
186
+ * Build SVG stroke attributes for one of a drop's parallel lines.
187
+ * The drop may have 1 or 2 parallel lines per its multiplicity.
188
+ */
189
+ function dropStrokeAttrs(style: RakeStrokeStyle, dashed: boolean, opacity = 1) {
162
190
  return {
163
191
  stroke: 'currentColor',
164
- 'stroke-width': 1.4,
192
+ 'stroke-width': style.strokeWidth,
165
193
  opacity,
166
- ...(useDash ? { 'stroke-dasharray': DASH_PATTERN } : {}),
194
+ ...(dashed ? { 'stroke-dasharray': DASH_PATTERN } : {}),
167
195
  };
168
196
  }
169
197
 
170
198
  function emitNav(uri: string) {
171
199
  emit('navigate', uri);
172
200
  }
201
+
202
+ function tooltipFor(member: PartitiveMemberLabeled): string | undefined {
203
+ return props.memberTooltip?.({ presence: member.presence, count: member.count, isDelimiting: member.isDelimiting });
204
+ }
173
205
  </script>
174
206
 
175
207
  <template>
@@ -212,71 +244,53 @@ function emitNav(uri: string) {
212
244
  class="text-[9px] italic fill-emerald-800 dark:fill-emerald-200/80"
213
245
  >{{ criterion }}</text>
214
246
 
215
- <!-- Stem (vertical from comprehensive to spine) -->
247
+ <!-- Stem: comprehensive → spine (single solid frame) -->
216
248
  <line
217
- v-if="lineVariant === 'single'"
218
249
  :x1="layout.centerX" :y1="layout.compY + layout.nodeH / 2"
219
250
  :x2="layout.centerX" :y2="layout.spineY"
220
- v-bind="strokeAttr('single')"
251
+ v-bind="frameStrokeAttrs"
221
252
  />
222
- <template v-else>
223
- <line
224
- :x1="layout.centerX - DOUBLE_GAP / 2" :y1="layout.compY + layout.nodeH / 2"
225
- :x2="layout.centerX - DOUBLE_GAP / 2" :y2="layout.spineY"
226
- v-bind="strokeAttr('double-solid')"
227
- />
228
- <line
229
- :x1="layout.centerX + DOUBLE_GAP / 2" :y1="layout.compY + layout.nodeH / 2"
230
- :x2="layout.centerX + DOUBLE_GAP / 2" :y2="layout.spineY"
231
- v-bind="strokeAttr(lineVariant, 1, lineVariant === 'solid-plus-dashed' ? 'dash' : undefined)"
232
- />
233
- </template>
234
253
 
235
- <!-- Spine (horizontal, from leftmost tooth to rightmost tooth or extended) -->
254
+ <!-- Spine: leftmost tooth → rightmost tooth (single solid frame;
255
+ extended for completeness: 'partial') -->
236
256
  <line
237
- v-if="lineVariant === 'single'"
238
257
  :x1="layout.leftX" :y1="layout.spineY"
239
258
  :x2="layout.spineEndX" :y2="layout.spineY"
240
- v-bind="strokeAttr('single')"
259
+ v-bind="frameStrokeAttrs"
241
260
  />
242
- <template v-else>
243
- <line
244
- :x1="layout.leftX" :y1="layout.spineY - DOUBLE_GAP / 2"
245
- :x2="layout.spineEndX" :y2="layout.spineY - DOUBLE_GAP / 2"
246
- v-bind="strokeAttr('double-solid')"
247
- />
248
- <line
249
- :x1="layout.leftX" :y1="layout.spineY + DOUBLE_GAP / 2"
250
- :x2="layout.spineEndX" :y2="layout.spineY + DOUBLE_GAP / 2"
251
- v-bind="strokeAttr(lineVariant, 1, lineVariant === 'solid-plus-dashed' ? 'dash' : undefined)"
252
- />
253
- </template>
254
261
 
255
262
  <!-- Drops + partitive nodes -->
256
263
  <g v-for="(slot, i) in layout.partitiveSlots" :key="i">
264
+ <!-- Primary drop line -->
257
265
  <line
258
266
  :x1="slot.x" :y1="layout.spineY"
259
267
  :x2="slot.x" :y2="layout.partY - layout.nodeH / 2"
260
- v-bind="strokeAttr('single', slot.member.certainty === 'possible' ? 0.5 : 1, slot.member.certainty === 'possible' ? 'dash' : undefined)"
268
+ v-bind="dropStrokeAttrs(slot.style, slot.style.primaryDashed)"
269
+ />
270
+ <!-- Secondary drop line (only for lineCount === 2; e.g. *_multiple, compulsory_at_least_one) -->
271
+ <line
272
+ v-if="slot.style.lineCount === 2"
273
+ :x1="slot.x + DOUBLE_GAP" :y1="layout.spineY"
274
+ :x2="slot.x + DOUBLE_GAP" :y2="layout.partY - layout.nodeH / 2"
275
+ v-bind="dropStrokeAttrs(slot.style, slot.style.secondaryDashed)"
261
276
  />
262
- <g class="cursor-pointer" @click="emitNav(slot.member.uri)">
277
+ <g class="cursor-pointer" :title="tooltipFor(slot.member)" @click="emitNav(slot.member.uri)">
263
278
  <rect
264
279
  :x="slot.x - layout.nodeW / 2"
265
280
  :y="layout.partY - layout.nodeH / 2"
266
281
  :width="layout.nodeW"
267
282
  :height="layout.nodeH"
268
283
  rx="6"
269
- :class="slot.member.certainty === 'possible'
270
- ? 'fill-emerald-50/60 dark:fill-emerald-900/20 stroke-current'
284
+ :class="slot.style.strokeWidth > 2
285
+ ? 'fill-emerald-100 dark:fill-emerald-900/50 stroke-current'
271
286
  : 'fill-emerald-50 dark:fill-emerald-900/30 stroke-current'"
272
- :stroke-dasharray="slot.member.certainty === 'possible' ? DASH_PATTERN : undefined"
273
- stroke-width="1.2"
287
+ :stroke-width="slot.style.strokeWidth > 2 ? 2.4 : 1.2"
274
288
  />
275
289
  <text
276
290
  :x="slot.x"
277
291
  text-anchor="middle"
278
- :class="slot.member.certainty === 'possible'
279
- ? 'text-[11px] fill-emerald-700/70 dark:fill-emerald-200/60 italic'
292
+ :class="slot.style.strokeWidth > 2
293
+ ? 'text-[11px] fill-emerald-900 dark:fill-emerald-50 font-medium'
280
294
  : 'text-[11px] fill-emerald-900 dark:fill-emerald-100'"
281
295
  >
282
296
  <tspan