@glossarist/concept-browser 0.7.103 → 0.7.105
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 +1 -1
- package/src/__tests__/components/rake-bundles.test.ts +20 -0
- package/src/__tests__/utils/external-detection.test.ts +85 -0
- package/src/components/PartitiveRelationDiagram.vue +12 -0
- package/src/components/relation-sphere/rake-bundles.ts +17 -0
- package/src/layouts/Default.astro +1 -6
- package/src/utils/external-detection.ts +74 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.105",
|
|
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": {
|
|
@@ -315,4 +315,24 @@ describe('drawRakeBundles — rake contract', () => {
|
|
|
315
315
|
expect(svg.querySelectorAll('circle.rake-junction').length).toBe(2);
|
|
316
316
|
expect(svg.querySelectorAll('path').length).toBe(2);
|
|
317
317
|
});
|
|
318
|
+
|
|
319
|
+
it('renders ellipsis "…" text when completeness is partial', () => {
|
|
320
|
+
const svg = makeSvg();
|
|
321
|
+
drawRakeBundles(svg, pos, {
|
|
322
|
+
isDark: false, color: '#0d9488', isMuted: false,
|
|
323
|
+
relations: [makeRelation({ completeness: 'partial' })],
|
|
324
|
+
});
|
|
325
|
+
const ellipsis = svg.querySelectorAll('text.rake-ellipsis');
|
|
326
|
+
expect(ellipsis.length).toBe(1);
|
|
327
|
+
expect(ellipsis[0].textContent).toBe('…');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('does NOT render ellipsis when completeness is complete', () => {
|
|
331
|
+
const svg = makeSvg();
|
|
332
|
+
drawRakeBundles(svg, pos, {
|
|
333
|
+
isDark: false, color: '#0d9488', isMuted: false,
|
|
334
|
+
relations: [makeRelation({ completeness: 'complete' })],
|
|
335
|
+
});
|
|
336
|
+
expect(svg.querySelectorAll('text.rake-ellipsis').length).toBe(0);
|
|
337
|
+
});
|
|
318
338
|
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
isExternalConcept,
|
|
4
|
+
hasProvidedBy,
|
|
5
|
+
isDanglingExternal,
|
|
6
|
+
formatExternalLabel,
|
|
7
|
+
type ConceptStoreLike,
|
|
8
|
+
} from '../../utils/external-detection';
|
|
9
|
+
|
|
10
|
+
const mockStore: ConceptStoreLike = {
|
|
11
|
+
lookup(ref) {
|
|
12
|
+
if (ref?.id === 'ext-1') {
|
|
13
|
+
return { status: 'external', relatedConcepts: [{ type: 'provided_by' }] };
|
|
14
|
+
}
|
|
15
|
+
if (ref?.id === 'ext-2') {
|
|
16
|
+
return { status: 'external', relatedConcepts: [] };
|
|
17
|
+
}
|
|
18
|
+
if (ref?.id === 'int-1') {
|
|
19
|
+
return { status: 'valid', relatedConcepts: [] };
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
describe('external-detection', () => {
|
|
26
|
+
describe('isExternalConcept', () => {
|
|
27
|
+
it('returns true when the concept status is external', () => {
|
|
28
|
+
expect(isExternalConcept({ source: 's', id: 'ext-1' }, mockStore)).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('returns false when the concept status is valid', () => {
|
|
32
|
+
expect(isExternalConcept({ source: 's', id: 'int-1' }, mockStore)).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('returns false when the concept is not found', () => {
|
|
36
|
+
expect(isExternalConcept({ source: 's', id: 'unknown' }, mockStore)).toBe(false);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns false when ref is null', () => {
|
|
40
|
+
expect(isExternalConcept(null, mockStore)).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns false when store is null', () => {
|
|
44
|
+
expect(isExternalConcept({ source: 's', id: 'ext-1' }, null)).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('hasProvidedBy', () => {
|
|
49
|
+
it('returns true when the external concept has a provided_by edge', () => {
|
|
50
|
+
expect(hasProvidedBy({ source: 's', id: 'ext-1' }, mockStore)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('returns false when the external concept has no provided_by edge', () => {
|
|
54
|
+
expect(hasProvidedBy({ source: 's', id: 'ext-2' }, mockStore)).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('returns false when the concept is not found', () => {
|
|
58
|
+
expect(hasProvidedBy({ source: 's', id: 'unknown' }, mockStore)).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('isDanglingExternal', () => {
|
|
63
|
+
it('returns false when the external concept has provided_by (resolved)', () => {
|
|
64
|
+
expect(isDanglingExternal({ source: 's', id: 'ext-1' }, mockStore)).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('returns true when the external concept has no provided_by (dangling)', () => {
|
|
68
|
+
expect(isDanglingExternal({ source: 's', id: 'ext-2' }, mockStore)).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('returns false for internal concepts', () => {
|
|
72
|
+
expect(isDanglingExternal({ source: 's', id: 'int-1' }, mockStore)).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('formatExternalLabel', () => {
|
|
77
|
+
it('wraps the label in parentheses for external concepts', () => {
|
|
78
|
+
expect(formatExternalLabel('precision condition', true)).toBe('(precision condition)');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('returns the label as-is for internal concepts', () => {
|
|
82
|
+
expect(formatExternalLabel('measurement unit', false)).toBe('measurement unit');
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -259,6 +259,18 @@ function tooltipFor(member: PartitiveMemberLabeled): string | undefined {
|
|
|
259
259
|
v-bind="frameStrokeAttrs"
|
|
260
260
|
/>
|
|
261
261
|
|
|
262
|
+
<!-- Ellipsis marker for partial completeness (ISO 704:2022) -->
|
|
263
|
+
<text
|
|
264
|
+
v-if="completeness === 'partial'"
|
|
265
|
+
:x="layout.spineEndX + 6" :y="layout.spineY + 4"
|
|
266
|
+
class="ellipsis-marker"
|
|
267
|
+
fill="currentColor"
|
|
268
|
+
font-size="12"
|
|
269
|
+
font-family="JetBrains Mono, monospace"
|
|
270
|
+
font-weight="600"
|
|
271
|
+
opacity="0.7"
|
|
272
|
+
>…</text>
|
|
273
|
+
|
|
262
274
|
<!-- Drops + partitive nodes -->
|
|
263
275
|
<g v-for="(slot, i) in layout.partitiveSlots" :key="i">
|
|
264
276
|
<!-- Primary drop line -->
|
|
@@ -140,6 +140,23 @@ export function drawRakeBundles(
|
|
|
140
140
|
drawSegment(svg, opts.color, spineStartX, spineStartY, spineEndX, spineEndY, { width: FRAME_STROKE_WIDTH });
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
// 6b. Ellipsis marker for partial completeness — ISO 704:2022
|
|
144
|
+
// "..." at the open end of the spine indicates further members
|
|
145
|
+
// exist but are not encoded.
|
|
146
|
+
if (rel.completeness === 'partial') {
|
|
147
|
+
const ellipsis = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
148
|
+
ellipsis.setAttribute('class', 'rake-ellipsis');
|
|
149
|
+
ellipsis.setAttribute('x', String(spineEndX + spineDirX * 4));
|
|
150
|
+
ellipsis.setAttribute('y', String(spineEndY + spineDirY * 4 + 3));
|
|
151
|
+
ellipsis.setAttribute('fill', opts.color);
|
|
152
|
+
ellipsis.setAttribute('font-size', '10');
|
|
153
|
+
ellipsis.setAttribute('font-family', 'JetBrains Mono, monospace');
|
|
154
|
+
ellipsis.setAttribute('font-weight', '600');
|
|
155
|
+
ellipsis.setAttribute('opacity', '0.8');
|
|
156
|
+
ellipsis.textContent = '…';
|
|
157
|
+
svg.appendChild(ellipsis);
|
|
158
|
+
}
|
|
159
|
+
|
|
143
160
|
// 7. Drops: tooth → partitive (per-member multiplicity + delimiting).
|
|
144
161
|
// 1 or 2 parallel lines per the style.
|
|
145
162
|
for (const tooth of teeth) {
|
|
@@ -68,12 +68,7 @@ const fontBody = brandingFonts?.body?.family
|
|
|
68
68
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
69
69
|
<link rel="preload" as="style" href={fontsUrl} onload="this.rel='stylesheet'">
|
|
70
70
|
<noscript><link href={fontsUrl} rel="stylesheet"></noscript>
|
|
71
|
-
<style
|
|
72
|
-
:root {
|
|
73
|
-
--font-header: ${fontHeader};
|
|
74
|
-
--font-body: ${fontBody};
|
|
75
|
-
}
|
|
76
|
-
</style>
|
|
71
|
+
<Fragment set:html={`<style>:root{--font-header:${fontHeader};--font-body:${fontBody}}</style>`} />
|
|
77
72
|
<script is:inline>
|
|
78
73
|
(function() {
|
|
79
74
|
const c = document.cookie.split(';').map(s => s.trim()).find(s => s.startsWith('theme='));
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* External concept detection — ISO 704:2022 external concepts.
|
|
3
|
+
*
|
|
4
|
+
* An "external concept" is one referenced from this dataset but defined
|
|
5
|
+
* elsewhere (status: 'external'). In diagrams, external concepts are
|
|
6
|
+
* shown in parentheses with a dashed border per ISO 704:2022 §5.5.4.3.1.
|
|
7
|
+
*
|
|
8
|
+
* Detection requires concept resolution (looking up the referenced
|
|
9
|
+
* concept's status), which is a consumer concern — the model itself
|
|
10
|
+
* stays pure. These utility functions take a conceptStore/lookup
|
|
11
|
+
* callback provided by the caller.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface ConceptLike {
|
|
15
|
+
status?: string | null;
|
|
16
|
+
relatedConcepts?: ReadonlyArray<{ type?: string | null }>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ConceptStoreLike {
|
|
20
|
+
lookup(ref: { source?: string | null; id?: string | null }): ConceptLike | null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface RefLike {
|
|
24
|
+
source?: string | null;
|
|
25
|
+
id?: string | null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Check if a member's concept resolves to status: external.
|
|
30
|
+
*/
|
|
31
|
+
export function isExternalConcept(
|
|
32
|
+
ref: RefLike | null | undefined,
|
|
33
|
+
store: ConceptStoreLike | null | undefined,
|
|
34
|
+
): boolean {
|
|
35
|
+
if (!ref || !store) return false;
|
|
36
|
+
const concept = store.lookup(ref);
|
|
37
|
+
return concept?.status === 'external';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Check if an external concept has a provided_by edge for resolution.
|
|
42
|
+
* Without provided_by, the reference dangles — the decomposition is
|
|
43
|
+
* incomplete.
|
|
44
|
+
*/
|
|
45
|
+
export function hasProvidedBy(
|
|
46
|
+
ref: RefLike | null | undefined,
|
|
47
|
+
store: ConceptStoreLike | null | undefined,
|
|
48
|
+
): boolean {
|
|
49
|
+
if (!ref || !store) return false;
|
|
50
|
+
const concept = store.lookup(ref);
|
|
51
|
+
if (!concept?.relatedConcepts) return false;
|
|
52
|
+
return concept.relatedConcepts.some(rc => rc.type === 'provided_by');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Check if an external concept's reference dangles (no provided_by edge).
|
|
57
|
+
*/
|
|
58
|
+
export function isDanglingExternal(
|
|
59
|
+
ref: RefLike | null | undefined,
|
|
60
|
+
store: ConceptStoreLike | null | undefined,
|
|
61
|
+
): boolean {
|
|
62
|
+
return isExternalConcept(ref, store) && !hasProvidedBy(ref, store);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Format a label for an external concept — parenthetical per ISO 704.
|
|
67
|
+
* Returns `"(label)"` for external concepts, `label` for internal.
|
|
68
|
+
*/
|
|
69
|
+
export function formatExternalLabel(
|
|
70
|
+
label: string,
|
|
71
|
+
isExternal: boolean,
|
|
72
|
+
): string {
|
|
73
|
+
return isExternal ? `(${label})` : label;
|
|
74
|
+
}
|