@glossarist/concept-browser 0.7.74 → 0.7.77
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/README.md +125 -449
- package/package.json +1 -1
- package/scripts/generate-data.mjs +44 -0
- package/src/components/AppSidebar.vue +1 -1
- package/src/components/NavIcon.vue +1 -0
- package/src/config/use-site-config.ts +3 -0
- package/src/i18n/locales/eng.yml +3 -0
- package/src/pages/[...path].astro +1 -0
- package/src/router/index.ts +6 -0
- package/src/views/DatasetView.vue +4 -0
- package/src/views/SourcesView.vue +134 -0
- package/src/views/StatsView.vue +120 -61
package/README.md
CHANGED
|
@@ -1,519 +1,195 @@
|
|
|
1
1
|
# Glossarist Concept Browser
|
|
2
2
|
|
|
3
|
-
A statically deployable
|
|
3
|
+
A statically deployable terminology browser. Install as an npm package, configure via YAML, build, deploy.
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
- [GeoLexica](https://www.geolexica.org) — IEC Electropedia + ISO/TC 211 + more
|
|
7
|
-
- [VIML](https://www.oimlsmart.org/vocab/) — OIML International Vocabulary of Legal Metrology
|
|
8
|
-
- [OIML Terms](https://metanorma.github.io/oiml-terms/) — OIML G 18 terminology
|
|
9
|
-
|
|
10
|
-
---
|
|
11
|
-
|
|
12
|
-
## Features
|
|
13
|
-
|
|
14
|
-
- **Multi-dataset browsing** — Concepts from multiple terminology registers in one place
|
|
15
|
-
- **Full multilingual support** — Definitions, notes, and examples in all available languages with i18n UI
|
|
16
|
-
- **Concept history timeline** — Review dates, decisions, and change notes per language
|
|
17
|
-
- **Relation sphere** — 3D sphere visualization of a concept's neighborhood with d3 force simulation, per-relation-type colors, and degree filtering
|
|
18
|
-
- **Edition series** — Sidebar timeline for vocabulary edition groups (e.g. VIML 1968/2000/2013/2022) with current-edition markers and supersession chain navigation
|
|
19
|
-
- **Cross-reference graph** — D3 force-directed graph showing concept relationships with dataset filtering
|
|
20
|
-
- **Dataset groups** — Organize datasets into lineage series, topic bundles, publication families, or curated collections. Each group kind has distinct sidebar rendering
|
|
21
|
-
- **Light/dark mode colors** — Per-dataset `{ light, dark }` color pairs. Per-relation-type colors configurable via site-config
|
|
22
|
-
- **RDF / SHACL outputs** — Every concept emits SHACL-conformant Turtle + JSON-LD. Dataset-level `dcat:Dataset`, group-level `dcat:DatasetSeries`/`dcat:Catalog`, vocabulary SKOS ConceptSchemes, bibliography `dcterms:BibliographicResource`, build provenance `prov:Activity`
|
|
23
|
-
- **Rich sidebar provenance** — Publication reference, owner, status, concept/language counts, sections tree from manifest data
|
|
24
|
-
- **Math rendering** — KaTeX rendering for AsciiMath notation in definitions (`stem:[...]`)
|
|
25
|
-
- **Responsive design** — Mobile-first layout with light/dark mode
|
|
26
|
-
- **Static deployment** — No server required. Deploy to any static host
|
|
27
|
-
|
|
28
|
-
---
|
|
29
|
-
|
|
30
|
-
## Quick Start (deployment repo)
|
|
31
|
-
|
|
32
|
-
A deployment repo is a separate repository that provides a `site-config.yml`, dataset source files, and content pages. The concept-browser is installed as an npm package.
|
|
5
|
+
## Quick Start
|
|
33
6
|
|
|
34
7
|
```bash
|
|
35
|
-
#
|
|
36
|
-
|
|
37
|
-
npm
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
8
|
+
# 1. Create your project
|
|
9
|
+
mkdir my-dictionary && cd my-dictionary
|
|
10
|
+
npm init -y
|
|
11
|
+
|
|
12
|
+
# 2. Install
|
|
13
|
+
npm install @glossarist/concept-browser
|
|
14
|
+
|
|
15
|
+
# 3. Create site-config.yml
|
|
16
|
+
cat > site-config.yml << 'EOF'
|
|
17
|
+
id: my-dictionary
|
|
18
|
+
title: My Dictionary
|
|
19
|
+
uri_base: https://example.com
|
|
20
|
+
datasets:
|
|
21
|
+
- id: my-vocab
|
|
22
|
+
title: My Vocabulary
|
|
23
|
+
local_path: datasets/my-vocab # or gcr_package: https://...
|
|
24
|
+
EOF
|
|
42
25
|
|
|
43
|
-
|
|
26
|
+
# 4. Build
|
|
27
|
+
npx concept-browser build
|
|
44
28
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
cd concept-browser
|
|
48
|
-
npm install
|
|
49
|
-
npm run dev
|
|
50
|
-
# Open http://localhost:5173
|
|
29
|
+
# 5. Deploy
|
|
30
|
+
# Upload dist/ to any static host (GitHub Pages, Netlify, S3, etc.)
|
|
51
31
|
```
|
|
52
32
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
---
|
|
56
|
-
|
|
57
|
-
## CLI Reference
|
|
33
|
+
## How It Works
|
|
58
34
|
|
|
59
35
|
```
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
Options:
|
|
72
|
-
--site <id> Site config ID (looks for site-config.yml in CWD)
|
|
73
|
-
|
|
74
|
-
Environment:
|
|
75
|
-
SITE_CONFIG Site config file path (highest priority)
|
|
76
|
-
SITE_ID Site config ID (same as --site)
|
|
77
|
-
GITHUB_TOKEN GitHub token for private repos
|
|
36
|
+
Your project (CWD) Package (read-only)
|
|
37
|
+
├── site-config.yml ──┐
|
|
38
|
+
├── datasets/ ─┤
|
|
39
|
+
│ └── my-vocab/ ─┤ ┌──────────────────────────┐
|
|
40
|
+
│ └── concepts/*.yaml ─┤ │ @glossarist/concept-browser │
|
|
41
|
+
├── public/ ─┤ ──> │ │
|
|
42
|
+
│ └── (logos, favicons) ─┤ │ generate-data.mjs │
|
|
43
|
+
├── .cb-content/ ← generated │ │ bridge-to-astro.mjs │
|
|
44
|
+
└── dist/ ← output │ │ astro build │
|
|
45
|
+
──┘ └──────────────────────────┘
|
|
78
46
|
```
|
|
79
47
|
|
|
80
|
-
|
|
48
|
+
The package reads your config and data, generates `.cb-content/` (content collections) and `dist/` (static site) in your project. The package itself (`node_modules/`) is never modified.
|
|
81
49
|
|
|
82
|
-
##
|
|
50
|
+
## Configuration
|
|
83
51
|
|
|
84
|
-
|
|
85
|
-
site-config.yml
|
|
86
|
-
└─> scripts/fetch-datasets.mjs (fetch from GCR, localPath, or sourceRepo)
|
|
87
|
-
└─> .datasets/{id}/concepts/*.yaml
|
|
88
|
-
└─> scripts/generate-data.mjs (YAML → JSON-LD + RDF artifacts)
|
|
89
|
-
└─> public/data/{id}/
|
|
90
|
-
├── manifest.json Dataset metadata (ref, owner, stats)
|
|
91
|
-
├── index.json Concept listing (chunked for large sets)
|
|
92
|
-
├── edges.json Pre-computed cross-reference + domain edges
|
|
93
|
-
├── domain-nodes.json Domain classification nodes
|
|
94
|
-
├── {register}.ttl Dataset-level RDF (dcat:Dataset + skos:ConceptScheme + sections)
|
|
95
|
-
├── bib.ttl Bibliography graph (dcterms:BibliographicResource)
|
|
96
|
-
└── concepts/*.json Individual concept documents
|
|
97
|
-
└─> public/data/_vocab.ttl Vocabulary graph (SKOS ConceptSchemes)
|
|
98
|
-
└─> public/data/activity/ Build provenance (prov:Activity per build)
|
|
99
|
-
└─> public/data/agents.ttl Contributor records (foaf:Person)
|
|
100
|
-
└─> public/data/versions.ttl Version chain (prov:Entity)
|
|
101
|
-
└─> scripts/build-edges.js (extract graph + domain edges)
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
---
|
|
52
|
+
Everything is in `site-config.yml`. See [`site-config.example.yml`](site-config.example.yml) for all options.
|
|
105
53
|
|
|
106
|
-
|
|
54
|
+
### Dataset location
|
|
107
55
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
### Top-level fields
|
|
56
|
+
Each dataset specifies where its source data lives. Three options:
|
|
111
57
|
|
|
112
58
|
```yaml
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
subtitle: International Vocabulary... # Short description
|
|
118
|
-
description: Terminology from... # Longer description
|
|
119
|
-
|
|
120
|
-
uiLanguages: # Available UI languages
|
|
121
|
-
- code: eng
|
|
122
|
-
label: English
|
|
123
|
-
- code: fra
|
|
124
|
-
label: Français
|
|
125
|
-
|
|
126
|
-
translations: # Localized title/subtitle/description
|
|
127
|
-
fra:
|
|
128
|
-
subtitle: Vocabulaire international de métrologie légale
|
|
129
|
-
description: Terminologie du Vocabulaire international...
|
|
130
|
-
|
|
131
|
-
defaults:
|
|
132
|
-
language: eng # Default concept language
|
|
133
|
-
|
|
134
|
-
copyright: "OIML" # Footer copyright text
|
|
135
|
-
```
|
|
59
|
+
datasets:
|
|
60
|
+
# Option A: Local directory with concepts/*.yaml
|
|
61
|
+
- id: my-vocab
|
|
62
|
+
local_path: datasets/my-vocab # relative to project root
|
|
136
63
|
|
|
137
|
-
|
|
64
|
+
# Option B: GCR package (auto-downloaded at build time)
|
|
65
|
+
- id: iev
|
|
66
|
+
gcr_package: https://github.com/org/repo/releases/download/v1.0/my-vocab.gcr
|
|
138
67
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
uri: "urn:oiml:pub:v:1:2022"
|
|
143
|
-
sourceRepo: https://github.com/metanorma/oiml-viml
|
|
144
|
-
localPath: viml-glossarist # Local dataset directory (relative to CWD)
|
|
145
|
-
title: "VIML — International Vocabulary of Legal Metrology"
|
|
146
|
-
description: "Terminology definitions from..."
|
|
147
|
-
owner: OIML
|
|
148
|
-
ref: "OIML V 1:2022" # Publication reference (shown in sidebar provenance)
|
|
149
|
-
color: "#004996"
|
|
150
|
-
tags: [metrology, legal, oiml]
|
|
151
|
-
languageOrder: [eng, fra]
|
|
68
|
+
# Option C: Git repository (cloned at build time)
|
|
69
|
+
- id: iso-terms
|
|
70
|
+
source_repo: https://github.com/org/iso-terms
|
|
152
71
|
```
|
|
153
72
|
|
|
154
|
-
|
|
73
|
+
### Colors
|
|
155
74
|
|
|
156
|
-
|
|
157
|
-
|-------|----------|-------------|
|
|
158
|
-
| `id` | yes | URL-safe identifier used in routes and data paths |
|
|
159
|
-
| `uri` | no | URI pattern for this dataset |
|
|
160
|
-
| `sourceRepo` | no | Git repository URL containing concept YAML files |
|
|
161
|
-
| `gcrPackage` | no | URL to a pre-built `.gcr` ZIP archive (alternative to sourceRepo) |
|
|
162
|
-
| `localPath` | no | Local directory with `concepts/` and `register.yaml` (relative to CWD) |
|
|
163
|
-
| `title` | no | Display name. Falls back to `register.yaml` name field |
|
|
164
|
-
| `description` | no | Shown on home page and about page |
|
|
165
|
-
| `owner` | no | Organization name shown in sidebar provenance |
|
|
166
|
-
| `ref` | no | Publication reference shown in sidebar provenance (e.g., "OIML V 1:2022") |
|
|
167
|
-
| `color` | no | Accent color — single hex (`"#004996"`) or `{ light, dark }` pair |
|
|
168
|
-
| `tags` | no | Array of labels shown on dataset card |
|
|
169
|
-
| `languageOrder` | no | Array of ISO 639-2 codes controlling display order |
|
|
170
|
-
| `translations` | no | Localized title and description per language |
|
|
75
|
+
Each dataset can have a color — single hex or light/dark pair:
|
|
171
76
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
77
|
+
```yaml
|
|
78
|
+
datasets:
|
|
79
|
+
- id: my-vocab
|
|
80
|
+
color: "#2563eb" # single color (both modes)
|
|
175
81
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
82
|
+
- id: legacy-vocab
|
|
83
|
+
color:
|
|
84
|
+
light: "#004996" # light mode
|
|
85
|
+
dark: "#3B82F6" # dark mode
|
|
86
|
+
```
|
|
180
87
|
|
|
181
|
-
|
|
88
|
+
Colors appear in the sidebar (color dot per dataset), sphere cards (top bar + tint), badges, and relationship edges. If omitted, a palette color is assigned automatically.
|
|
182
89
|
|
|
183
|
-
|
|
90
|
+
Group colors work the same way:
|
|
184
91
|
|
|
185
92
|
```yaml
|
|
186
|
-
|
|
93
|
+
dataset_groups:
|
|
94
|
+
- id: my-vocab-series
|
|
95
|
+
color: "#d97706"
|
|
187
96
|
```
|
|
188
97
|
|
|
189
|
-
|
|
98
|
+
### Logo
|
|
190
99
|
|
|
191
|
-
|
|
100
|
+
Place logo SVGs/PNGs in `public/` and reference them in `branding`:
|
|
192
101
|
|
|
193
102
|
```yaml
|
|
194
103
|
branding:
|
|
195
|
-
primaryColor: "#004996"
|
|
196
|
-
darkColor: "#003366"
|
|
197
|
-
fonts:
|
|
198
|
-
header:
|
|
199
|
-
family: "Source Serif 4"
|
|
200
|
-
source: "google"
|
|
201
|
-
weights: [400, 600]
|
|
202
|
-
body:
|
|
203
|
-
family: "Source Sans 3"
|
|
204
|
-
source: "google"
|
|
205
|
-
weights: [400, 500, 700]
|
|
206
104
|
logo:
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
localLight: logos/oiml-logo-icon-light.svg # Light variant (dark mode)
|
|
211
|
-
localDark: logos/oiml-logo-icon-dark.svg # Dark variant (light mode)
|
|
212
|
-
ownerName: OIML
|
|
213
|
-
ownerUrl: "https://www.oiml.org"
|
|
105
|
+
alt: My Dictionary
|
|
106
|
+
light: /images/logo-light.svg # shown in light mode
|
|
107
|
+
dark: /images/logo-dark.svg # shown in dark mode
|
|
214
108
|
```
|
|
215
109
|
|
|
216
|
-
|
|
110
|
+
If no logo is configured, the Glossarist logo is shown by default. The footer always shows the Glossarist logo ("Powered by Glossarist").
|
|
217
111
|
|
|
218
|
-
|
|
112
|
+
Favicons work the same way — place `favicon.svg`, `favicon.ico`, and `apple-touch-icon*.png` in `public/`. If not provided, the Glossarist favicon is used.
|
|
219
113
|
|
|
220
|
-
|
|
221
|
-
features:
|
|
222
|
-
news: false # enable news posts
|
|
223
|
-
stats: true # show statistics dashboard
|
|
224
|
-
graph: true # enable concept graph visualization
|
|
225
|
-
about: true # enable about page
|
|
226
|
-
search: true # enable full-text search
|
|
227
|
-
poweredBy:
|
|
228
|
-
title: "Glossarist"
|
|
229
|
-
url: "https://glossarist.org"
|
|
230
|
-
```
|
|
114
|
+
### About pages
|
|
231
115
|
|
|
232
|
-
|
|
116
|
+
Place JSON files in `public/pages/`:
|
|
233
117
|
|
|
234
|
-
```yaml
|
|
235
|
-
pages:
|
|
236
|
-
- type: about
|
|
237
|
-
route: about
|
|
238
|
-
title: About
|
|
239
|
-
icon: info
|
|
240
|
-
source: about.md
|
|
241
|
-
translations:
|
|
242
|
-
fra:
|
|
243
|
-
title: À propos
|
|
244
|
-
source: about-fra.md
|
|
245
118
|
```
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
```yaml
|
|
252
|
-
datasetGroups:
|
|
253
|
-
- id: viml
|
|
254
|
-
label: "VIML — International Vocabulary of Legal Metrology"
|
|
255
|
-
kind: lineage # edition series (same vocabulary, different years)
|
|
256
|
-
current: viml-2022 # current edition for the series
|
|
257
|
-
color:
|
|
258
|
-
light: "#004996"
|
|
259
|
-
dark: "#3B82F6"
|
|
260
|
-
datasets: [viml-2022, viml-2013, viml-2000, viml-1968]
|
|
261
|
-
translations:
|
|
262
|
-
fra:
|
|
263
|
-
label: "VIML — Vocabulaire international de métrologie légale"
|
|
264
|
-
|
|
265
|
-
- id: vim
|
|
266
|
-
label: "VIM — International Vocabulary of Metrology"
|
|
267
|
-
kind: lineage
|
|
268
|
-
current: vim-2012
|
|
269
|
-
color:
|
|
270
|
-
light: "#005A9C"
|
|
271
|
-
dark: "#2196F3"
|
|
272
|
-
datasets: [vim-2012, vim-2010, vim-2007, vim-1993]
|
|
119
|
+
public/
|
|
120
|
+
└── pages/
|
|
121
|
+
├── about.json → /about
|
|
122
|
+
└── my-vocab-about.json → /my-vocab-about
|
|
273
123
|
```
|
|
274
124
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
| Kind | Glyph | Description | Sidebar rendering |
|
|
278
|
-
|------|-------|-------------|-------------------|
|
|
279
|
-
| `lineage` | ⏳ | Editions of the same vocabulary over time | Compact timeline with year badges + current-edition star (✦) |
|
|
280
|
-
| `topic` | ◆ | Different vocabularies on the same subject | Standard list entries |
|
|
281
|
-
| `family` | ✦ | Related vocabularies from the same publisher | Standard list entries |
|
|
282
|
-
| `collection` | ❖ | Curated bundle of datasets | Standard list entries |
|
|
283
|
-
| `default` | ▸ | No special semantics (backward compat) | Standard list entries |
|
|
284
|
-
|
|
285
|
-
For `lineage` groups:
|
|
286
|
-
- The `current` field identifies the newest valid edition. If omitted, the newest member by year is auto-detected.
|
|
287
|
-
- Members are displayed as a timeline with year + reference + status badges.
|
|
288
|
-
- When an edition is active, the full expansion (sub-pages, sections tree, provenance) appears below the timeline entry — same as non-series datasets.
|
|
289
|
-
- A supersession chain card (`ConceptEditionRail`) appears in the concept detail sidebar, showing how the concept evolved across editions.
|
|
290
|
-
|
|
291
|
-
#### Group field reference
|
|
292
|
-
|
|
293
|
-
| Field | Required | Description |
|
|
294
|
-
|-------|----------|-------------|
|
|
295
|
-
| `id` | yes | Unique identifier for the group |
|
|
296
|
-
| `label` | yes | Display name shown as the collapsible group header |
|
|
297
|
-
| `kind` | no | Group kind: `lineage`, `topic`, `family`, `collection`, `default`. Defaults to `default` |
|
|
298
|
-
| `current` | no | For `lineage`: dataset id of the current (newest valid) edition |
|
|
299
|
-
| `description` | no | Short description of the group |
|
|
300
|
-
| `color` | no | Accent color — single hex or `{ light, dark }` pair |
|
|
301
|
-
| `datasets` | yes | Ordered array of dataset IDs belonging to this group |
|
|
302
|
-
| `translations` | no | Localized label and description per language |
|
|
303
|
-
|
|
304
|
-
Datasets not assigned to any group appear at the bottom of the dataset list.
|
|
125
|
+
Each file:
|
|
305
126
|
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
datasets:
|
|
312
|
-
- id: viml-2022
|
|
313
|
-
title: "OIML V 1:2022"
|
|
314
|
-
description: "Current edition with 135 terms..."
|
|
315
|
-
translations:
|
|
316
|
-
fra:
|
|
317
|
-
title: "OIML V 1:2022"
|
|
318
|
-
description: "Édition actuelle comprenant 135 termes..."
|
|
319
|
-
|
|
320
|
-
datasetGroups:
|
|
321
|
-
- id: viml
|
|
322
|
-
label: "International Vocabulary of Legal Metrology"
|
|
323
|
-
translations:
|
|
324
|
-
fra:
|
|
325
|
-
label: "Vocabulaire international de métrologie légale"
|
|
127
|
+
```json
|
|
128
|
+
{
|
|
129
|
+
"title": "About This Dictionary",
|
|
130
|
+
"html": "<p>Content here. Supports full HTML.</p>"
|
|
131
|
+
}
|
|
326
132
|
```
|
|
327
133
|
|
|
328
|
-
|
|
134
|
+
The page appears in the sidebar navigation automatically.
|
|
329
135
|
|
|
330
|
-
###
|
|
136
|
+
### Full example
|
|
331
137
|
|
|
332
|
-
|
|
138
|
+
See [`site-config.example.yml`](site-config.example.yml) for all options including UI languages, fonts, features, and dataset groups.
|
|
333
139
|
|
|
334
|
-
|
|
335
|
-
# Single hex (applied to both modes)
|
|
336
|
-
color: "#004996"
|
|
140
|
+
## CLI Commands
|
|
337
141
|
|
|
338
|
-
# Explicit light/dark pair
|
|
339
|
-
color:
|
|
340
|
-
light: "#004996"
|
|
341
|
-
dark: "#3B82F6"
|
|
342
142
|
```
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
```yaml
|
|
347
|
-
colors:
|
|
348
|
-
relationshipType:
|
|
349
|
-
supersedes:
|
|
350
|
-
light: "#FF0000"
|
|
351
|
-
dark: "#FF5555"
|
|
352
|
-
dataset:
|
|
353
|
-
viml-2022:
|
|
354
|
-
light: "#004996"
|
|
355
|
-
dark: "#3B82F6"
|
|
143
|
+
npx concept-browser fetch # Download datasets from GCR/repos
|
|
144
|
+
npx concept-browser generate # Convert YAML → JSON-LD + RDF artifacts
|
|
145
|
+
npx concept-browser build # Full pipeline: fetch + generate + build site
|
|
356
146
|
```
|
|
357
147
|
|
|
358
|
-
|
|
359
|
-
- `--rel-{category}-{light|dark}` per relationship category
|
|
360
|
-
- `--rel-type-{type}-{light|dark}` per relationship type
|
|
361
|
-
- `--concept-status-{status}-{light|dark}` per concept status
|
|
362
|
-
- `--group-kind-{kind}-{light|dark}` per group kind
|
|
363
|
-
- `--ds-{light|dark}` per dataset (scoped via `[data-ds]`)
|
|
148
|
+
## .gitignore for Your Project
|
|
364
149
|
|
|
365
|
-
|
|
150
|
+
Add these to your project's `.gitignore`:
|
|
366
151
|
|
|
367
|
-
```yaml
|
|
368
|
-
crossReferences:
|
|
369
|
-
refPrefixMap:
|
|
370
|
-
IEV: iev
|
|
371
|
-
urnStandardMap:
|
|
372
|
-
"14812": isotc204
|
|
373
152
|
```
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
### GitHub Pages (recommended)
|
|
380
|
-
|
|
381
|
-
1. Create a deployment repo with `site-config.yml`, dataset source, and content pages
|
|
382
|
-
2. Add a GitHub Actions workflow:
|
|
383
|
-
|
|
384
|
-
```yaml
|
|
385
|
-
jobs:
|
|
386
|
-
build:
|
|
387
|
-
runs-on: ubuntu-latest
|
|
388
|
-
steps:
|
|
389
|
-
- uses: actions/checkout@v4
|
|
390
|
-
- uses: actions/setup-node@v4
|
|
391
|
-
with:
|
|
392
|
-
node-version: 20
|
|
393
|
-
- name: Install concept-browser
|
|
394
|
-
run: |
|
|
395
|
-
npm install --ignore-scripts @glossarist/concept-browser
|
|
396
|
-
npm install --prefix node_modules/@glossarist/concept-browser sharp 2>/dev/null || true
|
|
397
|
-
- name: Build site
|
|
398
|
-
run: npx concept-browser build
|
|
399
|
-
env:
|
|
400
|
-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
401
|
-
- name: Upload artifact
|
|
402
|
-
uses: actions/upload-pages-artifact@v3
|
|
403
|
-
with:
|
|
404
|
-
path: dist
|
|
405
|
-
deploy:
|
|
406
|
-
needs: build
|
|
407
|
-
runs-on: ubuntu-latest
|
|
408
|
-
if: ${{ github.ref == 'refs/heads/main' }}
|
|
409
|
-
steps:
|
|
410
|
-
- uses: actions/deploy-pages@v4
|
|
153
|
+
.cb-content/
|
|
154
|
+
dist/
|
|
155
|
+
.datasets/
|
|
156
|
+
.gcr/
|
|
157
|
+
public/data/
|
|
411
158
|
```
|
|
412
159
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
### Other hosting platforms
|
|
416
|
-
|
|
417
|
-
The build produces static files in `dist/` with an SPA `404.html` fallback:
|
|
418
|
-
|
|
419
|
-
- **Netlify:** Build command `npx concept-browser build`, publish directory `dist`, add `_redirects` with `/* /index.html 200`
|
|
420
|
-
- **Vercel:** Framework Vite, build command `npx concept-browser build`, output directory `dist`
|
|
421
|
-
- **AWS S3 + CloudFront:** Upload `dist/`, error document `index.html`, configure CloudFront for SPA routing
|
|
422
|
-
|
|
423
|
-
### Known deployments
|
|
424
|
-
|
|
425
|
-
Sites currently powered by `@glossarist/concept-browser`. When cutting a release with breaking changes, bump `@glossarist/concept-browser` in each consumer repo and redeploy.
|
|
160
|
+
## Data Format
|
|
426
161
|
|
|
427
|
-
|
|
428
|
-
|---|---|
|
|
429
|
-
| [`geolexica/geolexica.github.io`](https://github.com/geolexica/geolexica.github.io) | https://www.geolexica.org |
|
|
430
|
-
| [`geolexica/isotc204.geolexica.org`](https://github.com/geolexica/isotc204.geolexica.org) | https://isotc204.geolexica.org |
|
|
431
|
-
| [`geolexica/isotc211.geolexica.org`](https://github.com/geolexica/isotc211.geolexica.org) | https://isotc211.geolexica.org |
|
|
432
|
-
| [`geolexica/osgeo.geolexica.org`](https://github.com/geolexica/osgeo.geolexica.org) | https://osgeo.geolexica.org |
|
|
433
|
-
| [`oimlsmart/vocab`](https://github.com/oimlsmart/vocab) | https://www.oimlsmart.org/vocab/ (VIML)¹ |
|
|
434
|
-
| [`metanorma/oiml-terms`](https://github.com/metanorma/oiml-terms) | https://metanorma.github.io/oiml-terms/ |
|
|
435
|
-
| [`glossarist/iala-vocab`](https://github.com/glossarist/iala-vocab) | https://glossarist.github.io/iala-vocab/ |
|
|
436
|
-
| [`metanorma/iso-10303-2-vocab`](https://github.com/metanorma/iso-10303-2-vocab) | https://metanorma.github.io/iso-10303-2-vocab/ |
|
|
437
|
-
|
|
438
|
-
To add a new deployment here, open a PR against this README.
|
|
439
|
-
|
|
440
|
-
¹ The VIML deployment moved from `metanorma/oiml-viml` to [`oimlsmart/vocab`](https://github.com/oimlsmart/vocab) (GitHub redirects the old name automatically), and its live URL moved from `metanorma.github.io/oiml-viml/` to `www.oimlsmart.org/vocab/`. The old `metanorma.github.io/oiml-viml/` URL returns 404 (Pages doesn't follow repo renames across orgs).
|
|
441
|
-
|
|
442
|
-
---
|
|
443
|
-
|
|
444
|
-
## Architecture
|
|
445
|
-
|
|
446
|
-
### Tech Stack
|
|
447
|
-
|
|
448
|
-
- **Vue 3** + **TypeScript** + **Vite**
|
|
449
|
-
- **Pinia** (state management)
|
|
450
|
-
- **Vue Router** (SPA navigation)
|
|
451
|
-
- **Tailwind CSS 3** (utility-first styling)
|
|
452
|
-
- **D3.js** (force-directed graph + relation sphere)
|
|
453
|
-
- **KaTeX** (math rendering)
|
|
454
|
-
- **n3** + **rdf-validate-shacl** (RDF parsing + SHACL validation)
|
|
455
|
-
- **fast-check** (property-based fuzz testing)
|
|
456
|
-
- **Vitest** with happy-dom
|
|
457
|
-
|
|
458
|
-
### Project structure
|
|
459
|
-
|
|
460
|
-
```
|
|
461
|
-
src/
|
|
462
|
-
├── adapters/ Data access layer (DatasetAdapter, AdapterFactory, UriRouter)
|
|
463
|
-
├── components/ Vue components (AppSidebar, ConceptDetail, RelationSphere, etc.)
|
|
464
|
-
│ ├── concept-rdf/ RDF graph IR + emitters + writers (concept, dataset, group, vocab, etc.)
|
|
465
|
-
│ └── groups/ OCP group renderer dispatcher + per-kind sidebar components
|
|
466
|
-
├── composables/ Vue composables (useDatasetSeries, useColorTheme, useSphereProjection)
|
|
467
|
-
├── config/ Site config types, group-types registry, group-renderers registry
|
|
468
|
-
├── data/ Static data (taxonomies.json, colors.json, concept-model shapes)
|
|
469
|
-
├── graph/ Graph engine for concept relationships
|
|
470
|
-
├── stores/ Pinia stores (vocabulary, ui)
|
|
471
|
-
├── views/ Page-level components (HomeView, DatasetView, ConceptView, GroupView)
|
|
472
|
-
├── i18n/ Internationalization (YAML locale files, auto-discovered)
|
|
473
|
-
└── utils/ Utilities (color-theme, dataset-style, relationship-categories, content-renderer)
|
|
474
|
-
|
|
475
|
-
cli/
|
|
476
|
-
└── index.mjs CLI entry point (fetch, generate, edges, build, doctor, normalize)
|
|
477
|
-
|
|
478
|
-
scripts/
|
|
479
|
-
├── fetch-datasets.mjs Clone + harmonize source repos
|
|
480
|
-
├── generate-data.mjs Convert YAML → JSON-LD + RDF artifacts
|
|
481
|
-
├── build-edges.js Extract cross-reference edges
|
|
482
|
-
├── process-about-pages.mjs Compile markdown/AsciiDoc about pages
|
|
483
|
-
├── validate-shacl.mjs SHACL validation gate
|
|
484
|
-
└── lib/ Build-time emitters (vocab, dataset, group, agents, version, etc.)
|
|
485
|
-
```
|
|
162
|
+
Each concept is a YAML file in `datasets/{id}/concepts/`:
|
|
486
163
|
|
|
164
|
+
```yaml
|
|
487
165
|
---
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
166
|
+
data:
|
|
167
|
+
identifier: "1-1-1"
|
|
168
|
+
localized_concepts:
|
|
169
|
+
eng: <uuid>
|
|
170
|
+
sources:
|
|
171
|
+
- type: authoritative
|
|
172
|
+
origin:
|
|
173
|
+
ref:
|
|
174
|
+
source: "ISO 9000"
|
|
175
|
+
definition:
|
|
176
|
+
- content: "The concept definition text."
|
|
177
|
+
terms:
|
|
178
|
+
- type: expression
|
|
179
|
+
normative_status: preferred
|
|
180
|
+
designation: "quality"
|
|
181
|
+
language_code: eng
|
|
497
182
|
---
|
|
498
|
-
|
|
499
|
-
## Testing
|
|
500
|
-
|
|
501
|
-
```bash
|
|
502
|
-
npm test # Run all tests (Vitest with happy-dom)
|
|
503
|
-
npm run test:watch # Watch mode
|
|
504
|
-
npm run mutation:test # Stryker mutation testing (scoped to RDF emitters, ~3-5 min)
|
|
505
183
|
```
|
|
506
184
|
|
|
507
|
-
|
|
508
|
-
- 1357+ tests across unit, round-trip, SHACL conformance, property-based fuzz, and build-integration layers
|
|
509
|
-
- 7-fixture concept corpus (minimal, multilingual, full-relationships, with-sources, with-non-verbal, with-dates, withdrawn)
|
|
510
|
-
- SHACL conformance gate: all fixtures validated against canonical shapes
|
|
511
|
-
- Property-based fuzz: 200 iterations × 4 invariants via fast-check
|
|
512
|
-
- 10,000-concept scale stress test (< 100ms)
|
|
513
|
-
- Stryker mutation testing scoped to concept/dataset/bibliography emitters
|
|
185
|
+
Or use a Glossarist GCR package — set `gcr_package` in your dataset config and the CLI downloads it automatically.
|
|
514
186
|
|
|
515
|
-
|
|
187
|
+
## Deployment
|
|
188
|
+
|
|
189
|
+
The build outputs a static site to `dist/`. Deploy it anywhere:
|
|
516
190
|
|
|
517
|
-
|
|
191
|
+
- **GitHub Pages**: Push `dist/` to `gh-pages` branch
|
|
192
|
+
- **Netlify/Vercel**: Set build command to `npx concept-browser build`, publish directory to `dist/`
|
|
193
|
+
- **S3/CloudFront**: `aws s3 sync dist/ s3://my-bucket/`
|
|
518
194
|
|
|
519
|
-
|
|
195
|
+
No server required. All data is pre-built into static JSON files.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glossarist/concept-browser",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.77",
|
|
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": {
|
|
@@ -920,6 +920,9 @@ async function processDataset(dir, register, opts) {
|
|
|
920
920
|
|
|
921
921
|
const dsRefMaps = { ...refMaps, register, designationLookup };
|
|
922
922
|
|
|
923
|
+
const sourceMap = new Map();
|
|
924
|
+
const relTypeCounts = {};
|
|
925
|
+
|
|
923
926
|
for (let i = 0; i < files.length; i++) {
|
|
924
927
|
const file = files[i];
|
|
925
928
|
try {
|
|
@@ -954,6 +957,28 @@ async function processDataset(dir, register, opts) {
|
|
|
954
957
|
status: conceptYaml.eng?.entry_status || 'valid',
|
|
955
958
|
});
|
|
956
959
|
|
|
960
|
+
const conceptSourceRefs = new Set();
|
|
961
|
+
const allSourceArrays = [
|
|
962
|
+
...(conceptYaml._sources || []),
|
|
963
|
+
...opts.languages.flatMap(l => conceptYaml[l]?.sources || []),
|
|
964
|
+
];
|
|
965
|
+
for (const src of allSourceArrays) {
|
|
966
|
+
const ref = src.origin?.ref?.source || src.origin?.ref?.id || 'Unknown';
|
|
967
|
+
conceptSourceRefs.add(ref);
|
|
968
|
+
if (!sourceMap.has(ref)) {
|
|
969
|
+
sourceMap.set(ref, { ref, types: new Set(), conceptIds: [] });
|
|
970
|
+
}
|
|
971
|
+
if (src.type) sourceMap.get(ref).types.add(src.type);
|
|
972
|
+
}
|
|
973
|
+
for (const ref of conceptSourceRefs) {
|
|
974
|
+
sourceMap.get(ref)?.conceptIds.push(termid);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
for (const r of conceptYaml._related || []) {
|
|
978
|
+
const type = r.type || 'unknown';
|
|
979
|
+
relTypeCounts[type] = (relTypeCounts[type] || 0) + 1;
|
|
980
|
+
}
|
|
981
|
+
|
|
957
982
|
for (const lang of opts.languages) {
|
|
958
983
|
const lc = conceptYaml[lang];
|
|
959
984
|
if (lc) {
|
|
@@ -1072,6 +1097,23 @@ async function processDataset(dir, register, opts) {
|
|
|
1072
1097
|
}
|
|
1073
1098
|
}
|
|
1074
1099
|
|
|
1100
|
+
const sourceStats = Array.from(sourceMap.values())
|
|
1101
|
+
.map(s => ({
|
|
1102
|
+
ref: s.ref,
|
|
1103
|
+
types: Array.from(s.types),
|
|
1104
|
+
conceptCount: s.conceptIds.length,
|
|
1105
|
+
conceptIds: s.conceptIds,
|
|
1106
|
+
}))
|
|
1107
|
+
.sort((a, b) => b.conceptCount - a.conceptCount);
|
|
1108
|
+
const totalRelationships = Object.values(relTypeCounts).reduce((a, b) => a + b, 0);
|
|
1109
|
+
|
|
1110
|
+
writeJson(path.join(DATA, register, 'stats.json'), {
|
|
1111
|
+
sourceCount: sourceStats.length,
|
|
1112
|
+
sources: sourceStats,
|
|
1113
|
+
relationshipCount: totalRelationships,
|
|
1114
|
+
relationshipTypes: relTypeCounts,
|
|
1115
|
+
});
|
|
1116
|
+
|
|
1075
1117
|
const manifest = {
|
|
1076
1118
|
id: register,
|
|
1077
1119
|
datasetUri: opts.datasetUri,
|
|
@@ -1082,6 +1124,8 @@ async function processDataset(dir, register, opts) {
|
|
|
1082
1124
|
baseUrl: `/data/${register}`,
|
|
1083
1125
|
languages: opts.languages,
|
|
1084
1126
|
conceptCount: concepts.length,
|
|
1127
|
+
sourceCount: sourceStats.length,
|
|
1128
|
+
relationshipCount: totalRelationships,
|
|
1085
1129
|
conceptUrlTemplate: '{baseUrl}/concepts/{conceptId}.json',
|
|
1086
1130
|
indexUrl: '{baseUrl}/index.json',
|
|
1087
1131
|
contextUrl: 'https://glossarist.org/ns/context.jsonld',
|
|
@@ -136,7 +136,7 @@ const filteredGlobalPages = computed(() =>
|
|
|
136
136
|
|
|
137
137
|
// Show only standard dataset pages (Concepts, Statistics, About)
|
|
138
138
|
const filteredDatasetPages = computed(() =>
|
|
139
|
-
datasetPages.value.filter(p => ['', 'stats', 'about'].includes(p.route || ''))
|
|
139
|
+
datasetPages.value.filter(p => ['', 'stats', 'sources', 'about'].includes(p.route || ''))
|
|
140
140
|
);
|
|
141
141
|
|
|
142
142
|
const currentManifest = computed(() => store.manifests.get(currentDataset.value));
|
|
@@ -9,6 +9,7 @@ const icons: Record<string, string> = {
|
|
|
9
9
|
chart: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z',
|
|
10
10
|
list: 'M4 6h16M4 10h16M4 14h16M4 18h16',
|
|
11
11
|
schema: 'M4 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z',
|
|
12
|
+
database: 'M4 7c0 1.1 3.6 2 8 2s8-.9 8-2M4 7c0-1.1 3.6-2 8-2s8 .9 8 2M4 7v10c0 1.1 3.6 2 8 2s8-.9 8-2V7M4 12c0 1.1 3.6 2 8 2s8-.9 8-2',
|
|
12
13
|
};
|
|
13
14
|
|
|
14
15
|
defineProps<{ name: string }>();
|
|
@@ -157,6 +157,9 @@ function synthesizeDatasetPages(features?: Record<string, unknown>, pages?: Page
|
|
|
157
157
|
if (features?.stats !== false && !declaredRoutes.has('stats')) {
|
|
158
158
|
result.push({ type: 'stats', route: 'stats', title: 'Statistics', icon: 'chart', datasetScoped: true });
|
|
159
159
|
}
|
|
160
|
+
if (!declaredRoutes.has('sources')) {
|
|
161
|
+
result.push({ type: 'sources', route: 'sources', title: 'Sources', icon: 'database', datasetScoped: true });
|
|
162
|
+
}
|
|
160
163
|
if (features?.about !== false && !declaredRoutes.has('about')) {
|
|
161
164
|
result.push({ type: 'about', route: 'about', title: 'About', icon: 'info', datasetScoped: true });
|
|
162
165
|
}
|
package/src/i18n/locales/eng.yml
CHANGED
|
@@ -9,6 +9,7 @@ nav.ontology: Ontology
|
|
|
9
9
|
nav.concepts: Concepts
|
|
10
10
|
nav.stats: Statistics
|
|
11
11
|
nav.about: About
|
|
12
|
+
nav.sources: Sources
|
|
12
13
|
nav.navigation: Navigation
|
|
13
14
|
nav.datasets: Datasets
|
|
14
15
|
|
|
@@ -121,6 +122,8 @@ lang.termOnlyIn: Term only in
|
|
|
121
122
|
# Dataset view
|
|
122
123
|
dataset.concepts: concepts
|
|
123
124
|
dataset.languages: languages
|
|
125
|
+
dataset.sources: sources
|
|
126
|
+
dataset.relationships: relationships
|
|
124
127
|
dataset.filterPlaceholder: "Filter concepts... (press /)"
|
|
125
128
|
dataset.of: of
|
|
126
129
|
dataset.in: in
|
|
@@ -15,6 +15,7 @@ export async function getStaticPaths() {
|
|
|
15
15
|
for (const ds of datasets) {
|
|
16
16
|
paths.push({ params: { path: `dataset/${ds.data.id}/about` } });
|
|
17
17
|
paths.push({ params: { path: `dataset/${ds.data.id}/stats` } });
|
|
18
|
+
paths.push({ params: { path: `dataset/${ds.data.id}/sources` } });
|
|
18
19
|
for (const g of await getCollection('groups')) {
|
|
19
20
|
paths.push({ params: { path: `group/${g.data.id}/about` } });
|
|
20
21
|
}
|
package/src/router/index.ts
CHANGED
|
@@ -31,6 +31,12 @@ export const routes: RouteRecordRaw[] = [
|
|
|
31
31
|
component: () => import('../views/PageView.vue'),
|
|
32
32
|
props: true,
|
|
33
33
|
},
|
|
34
|
+
{
|
|
35
|
+
path: '/dataset/:registerId/sources',
|
|
36
|
+
name: 'sources',
|
|
37
|
+
component: () => import('../views/SourcesView.vue'),
|
|
38
|
+
props: true,
|
|
39
|
+
},
|
|
34
40
|
{
|
|
35
41
|
path: '/group/:groupId',
|
|
36
42
|
name: 'group',
|
|
@@ -331,6 +331,10 @@ function clearSection() {
|
|
|
331
331
|
<router-link :to="{ name: 'about', params: { registerId } }" class="badge badge-purple hover:opacity-80 transition-opacity">
|
|
332
332
|
{{ t('nav.about') }}
|
|
333
333
|
</router-link>
|
|
334
|
+
<router-link v-if="manifest.sourceCount" :to="{ name: 'sources', params: { registerId } }" class="badge badge-gray hover:opacity-80 transition-opacity">
|
|
335
|
+
{{ manifest.sourceCount }} {{ t('dataset.sources') }}
|
|
336
|
+
</router-link>
|
|
337
|
+
<span v-if="manifest.relationshipCount" class="badge badge-yellow">{{ manifest.relationshipCount.toLocaleString() }} {{ t('dataset.relationships') }}</span>
|
|
334
338
|
</div>
|
|
335
339
|
</div>
|
|
336
340
|
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { ref, computed, watch } from 'vue';
|
|
3
|
+
import { useVocabularyStore } from '../stores/vocabulary';
|
|
4
|
+
import { useDatasetLoader } from '../composables/use-dataset-loader';
|
|
5
|
+
import { useDsStyle } from '../utils/dataset-style';
|
|
6
|
+
import { useI18n } from '../i18n';
|
|
7
|
+
|
|
8
|
+
const props = defineProps<{ registerId?: string }>();
|
|
9
|
+
|
|
10
|
+
const store = useVocabularyStore();
|
|
11
|
+
const { getColor } = useDsStyle();
|
|
12
|
+
const { loading, localError, ensureLoaded, resolvedId } = useDatasetLoader(() => props.registerId);
|
|
13
|
+
const { t } = useI18n();
|
|
14
|
+
|
|
15
|
+
const manifest = computed(() => store.manifests.get(resolvedId.value));
|
|
16
|
+
|
|
17
|
+
interface SourceEntry {
|
|
18
|
+
ref: string;
|
|
19
|
+
types: string[];
|
|
20
|
+
conceptCount: number;
|
|
21
|
+
conceptIds: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const stats = ref<{ sources: SourceEntry[]; sourceCount: number; relationshipCount: number; relationshipTypes: Record<string, number> } | null>(null);
|
|
25
|
+
const expandedSource = ref<string | null>(null);
|
|
26
|
+
const filterText = ref('');
|
|
27
|
+
|
|
28
|
+
async function loadStats() {
|
|
29
|
+
if (!resolvedId.value) return;
|
|
30
|
+
const base = import.meta.env.BASE_URL;
|
|
31
|
+
try {
|
|
32
|
+
const resp = await fetch(`${base}data/${resolvedId.value}/stats.json`);
|
|
33
|
+
if (resp.ok) {
|
|
34
|
+
stats.value = await resp.json();
|
|
35
|
+
}
|
|
36
|
+
} catch (e) {
|
|
37
|
+
console.warn('Failed to load stats:', e);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
watch(() => resolvedId.value, () => { loadStats(); }, { immediate: true });
|
|
42
|
+
|
|
43
|
+
const filteredSources = computed(() => {
|
|
44
|
+
if (!stats.value?.sources) return [];
|
|
45
|
+
const q = filterText.value.toLowerCase().trim();
|
|
46
|
+
if (!q) return stats.value.sources;
|
|
47
|
+
return stats.value.sources.filter(s =>
|
|
48
|
+
s.ref.toLowerCase().includes(q) ||
|
|
49
|
+
s.types.some(t => t.toLowerCase().includes(q))
|
|
50
|
+
);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function sourceTypeBadge(type: string): string {
|
|
54
|
+
if (type === 'authoritative') return 'badge-green';
|
|
55
|
+
if (type === 'lineage') return 'badge-yellow';
|
|
56
|
+
return 'badge-gray';
|
|
57
|
+
}
|
|
58
|
+
</script>
|
|
59
|
+
|
|
60
|
+
<template>
|
|
61
|
+
<div class="max-w-4xl mx-auto">
|
|
62
|
+
<div v-if="loading" class="flex items-center justify-center py-20">
|
|
63
|
+
<div class="skeleton h-8 w-64"></div>
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
<div v-else-if="localError" class="card p-8 text-center">
|
|
67
|
+
<p class="text-red-600">{{ localError }}</p>
|
|
68
|
+
</div>
|
|
69
|
+
|
|
70
|
+
<template v-else-if="manifest">
|
|
71
|
+
<div class="mb-6">
|
|
72
|
+
<div class="text-xs text-ink-400 mb-2">
|
|
73
|
+
<router-link :to="{ name: 'dataset', params: { registerId } }" class="hover:text-blue-600">{{ manifest.title }}</router-link>
|
|
74
|
+
<span class="mx-1">›</span>
|
|
75
|
+
<span>{{ t('nav.sources') }}</span>
|
|
76
|
+
</div>
|
|
77
|
+
<h1 class="font-serif text-3xl text-ink-800 dark:text-ink-100">{{ t('nav.sources') }}</h1>
|
|
78
|
+
<p class="text-ink-400 mt-1" v-if="stats">{{ stats.sourceCount }} {{ t('dataset.sources') }} · {{ manifest.conceptCount.toLocaleString() }} {{ t('dataset.concepts') }}</p>
|
|
79
|
+
</div>
|
|
80
|
+
|
|
81
|
+
<div v-if="!stats" class="card p-8 text-center text-ink-400">
|
|
82
|
+
Loading sources…
|
|
83
|
+
</div>
|
|
84
|
+
|
|
85
|
+
<template v-else>
|
|
86
|
+
<div class="card p-3 mb-4">
|
|
87
|
+
<input
|
|
88
|
+
v-model="filterText"
|
|
89
|
+
type="text"
|
|
90
|
+
:placeholder="'Filter sources...'"
|
|
91
|
+
class="w-full px-3 py-2 text-sm border border-ink-100 dark:border-ink-700 rounded-lg bg-surface-raised dark:bg-ink-800 text-ink-700 dark:text-ink-200 focus:outline-none focus:border-blue-400"
|
|
92
|
+
/>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div class="space-y-2">
|
|
96
|
+
<div v-for="src in filteredSources" :key="src.ref" class="card overflow-hidden">
|
|
97
|
+
<button
|
|
98
|
+
class="w-full text-left p-4 flex items-center gap-3 hover:bg-ink-50/50 dark:hover:bg-ink-800/30 transition-colors"
|
|
99
|
+
@click="expandedSource = expandedSource === src.ref ? null : src.ref"
|
|
100
|
+
>
|
|
101
|
+
<span
|
|
102
|
+
class="w-3 h-3 rounded-full flex-shrink-0"
|
|
103
|
+
:style="{ backgroundColor: getColor(resolvedId) }"
|
|
104
|
+
></span>
|
|
105
|
+
<div class="flex-1 min-w-0">
|
|
106
|
+
<div class="font-medium text-ink-800 dark:text-ink-100 truncate">{{ src.ref }}</div>
|
|
107
|
+
<div class="flex items-center gap-1.5 mt-1">
|
|
108
|
+
<span v-for="tp in src.types" :key="tp" class="badge text-[9px]" :class="sourceTypeBadge(tp)">{{ tp }}</span>
|
|
109
|
+
<span class="text-xs text-ink-400">{{ src.conceptCount }} {{ t('dataset.concepts') }}</span>
|
|
110
|
+
</div>
|
|
111
|
+
</div>
|
|
112
|
+
<svg class="w-4 h-4 text-ink-300 transition-transform flex-shrink-0" :class="{ 'rotate-180': expandedSource === src.ref }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
113
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
|
|
114
|
+
</svg>
|
|
115
|
+
</button>
|
|
116
|
+
|
|
117
|
+
<div v-if="expandedSource === src.ref" class="border-t border-ink-100/60 dark:border-ink-700/40 p-4">
|
|
118
|
+
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-1">
|
|
119
|
+
<router-link
|
|
120
|
+
v-for="cid in src.conceptIds"
|
|
121
|
+
:key="cid"
|
|
122
|
+
:to="{ name: 'concept', params: { registerId, conceptId: cid } }"
|
|
123
|
+
class="block px-2 py-1 rounded text-sm text-ink-600 dark:text-ink-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:text-blue-700 dark:hover:text-blue-300 transition-colors"
|
|
124
|
+
>
|
|
125
|
+
<code class="text-xs text-ink-400 font-mono mr-1">{{ cid }}</code>
|
|
126
|
+
</router-link>
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
</template>
|
|
132
|
+
</template>
|
|
133
|
+
</div>
|
|
134
|
+
</template>
|
package/src/views/StatsView.vue
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
-
import { computed } from 'vue';
|
|
2
|
+
import { ref, computed, watch } from 'vue';
|
|
3
3
|
import { useVocabularyStore } from '../stores/vocabulary';
|
|
4
4
|
import { useDsStyle } from '../utils/dataset-style';
|
|
5
5
|
import { useDatasetLoader } from '../composables/use-dataset-loader';
|
|
@@ -15,51 +15,37 @@ const { t } = useI18n();
|
|
|
15
15
|
|
|
16
16
|
const manifest = computed(() => store.manifests.get(resolvedId.value));
|
|
17
17
|
|
|
18
|
-
interface LangStat {
|
|
19
|
-
lang: string;
|
|
20
|
-
terms: number;
|
|
21
|
-
definitions: number;
|
|
22
|
-
}
|
|
18
|
+
interface LangStat { lang: string; terms: number; definitions: number; }
|
|
23
19
|
|
|
24
|
-
const
|
|
20
|
+
const langStats = computed(() => {
|
|
25
21
|
const m = manifest.value;
|
|
26
22
|
if (!m) return { langs: [], total: 0 };
|
|
27
|
-
|
|
28
|
-
// Scan the index for actual language coverage
|
|
29
23
|
const adapter = store.datasets.get(resolvedId.value);
|
|
30
24
|
const conceptCounts: Record<string, number> = {};
|
|
31
|
-
|
|
32
25
|
if (adapter) {
|
|
33
|
-
const
|
|
34
|
-
for (const c of concepts) {
|
|
26
|
+
for (const c of adapter.getConcepts()) {
|
|
35
27
|
if (!c) continue;
|
|
36
28
|
for (const lang of Object.keys(c.designations)) {
|
|
37
29
|
conceptCounts[lang] = (conceptCounts[lang] || 0) + 1;
|
|
38
30
|
}
|
|
39
31
|
}
|
|
40
32
|
}
|
|
41
|
-
|
|
42
|
-
// Merge with manifest languageStats (for definition counts)
|
|
43
33
|
const ls = m.languageStats || {};
|
|
44
34
|
const allLangs = new Set([...Object.keys(conceptCounts), ...m.languages]);
|
|
45
|
-
|
|
46
35
|
const langs: LangStat[] = [...allLangs].map(lang => ({
|
|
47
36
|
lang,
|
|
48
37
|
terms: ls[lang]?.terms ?? conceptCounts[lang] ?? 0,
|
|
49
38
|
definitions: ls[lang]?.definitions ?? conceptCounts[lang] ?? 0,
|
|
50
39
|
}));
|
|
51
|
-
|
|
52
|
-
// Sort: eng first, then by term count descending
|
|
53
40
|
langs.sort((a, b) => {
|
|
54
41
|
if (a.lang === 'eng') return -1;
|
|
55
42
|
if (b.lang === 'eng') return 1;
|
|
56
43
|
return b.terms - a.terms;
|
|
57
44
|
});
|
|
58
|
-
|
|
59
45
|
return { langs, total: m.conceptCount };
|
|
60
46
|
});
|
|
61
47
|
|
|
62
|
-
const maxTerms = computed(() => Math.max(...
|
|
48
|
+
const maxTerms = computed(() => Math.max(...langStats.value.langs.map(l => l.terms), 1));
|
|
63
49
|
|
|
64
50
|
function coverageColor(ratio: number): string {
|
|
65
51
|
if (ratio >= 0.8) return 'bg-emerald-500';
|
|
@@ -67,11 +53,49 @@ function coverageColor(ratio: number): string {
|
|
|
67
53
|
if (ratio >= 0.25) return 'bg-amber-500';
|
|
68
54
|
return 'bg-red-400';
|
|
69
55
|
}
|
|
56
|
+
|
|
57
|
+
interface StatsData {
|
|
58
|
+
sourceCount: number;
|
|
59
|
+
sources: Array<{ ref: string; types: string[]; conceptCount: number }>;
|
|
60
|
+
relationshipCount: number;
|
|
61
|
+
relationshipTypes: Record<string, number>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const statsData = ref<StatsData | null>(null);
|
|
65
|
+
|
|
66
|
+
async function loadStats() {
|
|
67
|
+
if (!resolvedId.value) return;
|
|
68
|
+
const base = import.meta.env.BASE_URL;
|
|
69
|
+
try {
|
|
70
|
+
const resp = await fetch(`${base}data/${resolvedId.value}/stats.json`);
|
|
71
|
+
if (resp.ok) statsData.value = await resp.json();
|
|
72
|
+
} catch {}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
watch(() => resolvedId.value, () => { loadStats(); }, { immediate: true });
|
|
76
|
+
|
|
77
|
+
const relEntries = computed(() => {
|
|
78
|
+
if (!statsData.value?.relationshipTypes) return [];
|
|
79
|
+
return Object.entries(statsData.value.relationshipTypes)
|
|
80
|
+
.sort(([, a], [, b]) => b - a);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const maxRel = computed(() => Math.max(...relEntries.value.map(([, n]) => n), 1));
|
|
84
|
+
|
|
85
|
+
const topSources = computed(() => {
|
|
86
|
+
if (!statsData.value?.sources) return [];
|
|
87
|
+
return statsData.value.sources.slice(0, 10);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
function sourceTypeBadge(type: string): string {
|
|
91
|
+
if (type === 'authoritative') return 'badge-green';
|
|
92
|
+
if (type === 'lineage') return 'badge-yellow';
|
|
93
|
+
return 'badge-gray';
|
|
94
|
+
}
|
|
70
95
|
</script>
|
|
71
96
|
|
|
72
97
|
<template>
|
|
73
98
|
<div class="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
74
|
-
<!-- Breadcrumb -->
|
|
75
99
|
<nav aria-label="Breadcrumb" class="flex items-center gap-1.5 text-sm text-ink-400 mb-6">
|
|
76
100
|
<router-link :to="{ name: 'home' }" class="hover:text-ink-700 transition-colors">{{ t('nav.home') }}</router-link>
|
|
77
101
|
<span class="text-ink-200">/</span>
|
|
@@ -84,9 +108,7 @@ function coverageColor(ratio: number): string {
|
|
|
84
108
|
<div class="animate-pulse space-y-6">
|
|
85
109
|
<div class="h-8 bg-ink-100 rounded w-32"></div>
|
|
86
110
|
<div class="h-4 bg-ink-100 rounded w-64"></div>
|
|
87
|
-
<div class="card overflow-hidden">
|
|
88
|
-
<div class="h-80 bg-ink-50"></div>
|
|
89
|
-
</div>
|
|
111
|
+
<div class="card overflow-hidden"><div class="h-80 bg-ink-50"></div></div>
|
|
90
112
|
</div>
|
|
91
113
|
</template>
|
|
92
114
|
<template v-else-if="localError">
|
|
@@ -99,46 +121,83 @@ function coverageColor(ratio: number): string {
|
|
|
99
121
|
<template v-else-if="manifest">
|
|
100
122
|
<h1 class="font-serif text-3xl text-ink-800 mb-2">{{ t('stats.title') }}</h1>
|
|
101
123
|
<p class="text-ink-400 mb-8">
|
|
102
|
-
{{ t('stats.summary', { count:
|
|
124
|
+
{{ t('stats.summary', { count: langStats.total.toLocaleString(), langCount: String(manifest.languages.length) }) }}
|
|
103
125
|
</p>
|
|
104
126
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
<
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
<
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
127
|
+
<div v-if="statsData" class="flex flex-wrap gap-2 mb-8">
|
|
128
|
+
<span class="badge badge-purple">{{ statsData.sourceCount }} {{ t('dataset.sources') }}</span>
|
|
129
|
+
<span class="badge badge-yellow">{{ statsData.relationshipCount.toLocaleString() }} {{ t('dataset.relationships') }}</span>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
133
|
+
<div>
|
|
134
|
+
<h2 class="section-label mb-3">{{ t('stats.language') }}</h2>
|
|
135
|
+
<div class="card overflow-x-auto">
|
|
136
|
+
<table class="w-full text-sm">
|
|
137
|
+
<thead>
|
|
138
|
+
<tr class="border-b border-ink-100/60 bg-ink-50">
|
|
139
|
+
<th class="text-left px-4 py-2.5 text-ink-600 font-medium text-xs uppercase tracking-wide">{{ t('stats.language') }}</th>
|
|
140
|
+
<th class="text-right px-4 py-2.5 text-ink-600 font-medium text-xs uppercase tracking-wide">{{ t('stats.terms') }}</th>
|
|
141
|
+
<th class="text-right px-4 py-2.5 text-ink-600 font-medium text-xs uppercase tracking-wide">{{ t('stats.definitions') }}</th>
|
|
142
|
+
</tr>
|
|
143
|
+
</thead>
|
|
144
|
+
<tbody>
|
|
145
|
+
<tr v-for="s in langStats.langs" :key="s.lang" class="border-b border-ink-50 last:border-0">
|
|
146
|
+
<td class="px-4 py-2.5">
|
|
147
|
+
<span class="text-xs font-semibold text-ink-500 bg-ink-50 px-1.5 py-0.5 rounded">{{ langLabel(s.lang) }}</span>
|
|
148
|
+
<span class="ml-2 font-medium text-ink-800">{{ langName(s.lang) }}</span>
|
|
149
|
+
</td>
|
|
150
|
+
<td class="text-right px-4 py-2.5 font-mono text-ink-700">{{ s.terms.toLocaleString() }}</td>
|
|
151
|
+
<td class="text-right px-4 py-2.5 font-mono text-ink-700">{{ s.definitions.toLocaleString() }}</td>
|
|
152
|
+
</tr>
|
|
153
|
+
</tbody>
|
|
154
|
+
</table>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<div v-if="relEntries.length">
|
|
159
|
+
<h2 class="section-label mb-3">{{ t('dataset.relationships') }}</h2>
|
|
160
|
+
<div class="card p-4 space-y-3">
|
|
161
|
+
<div v-for="[type, count] in relEntries" :key="type">
|
|
162
|
+
<div class="flex items-center justify-between mb-1">
|
|
163
|
+
<span class="text-sm font-medium text-ink-700">{{ type }}</span>
|
|
164
|
+
<span class="text-sm font-mono text-ink-500">{{ count.toLocaleString() }}</span>
|
|
165
|
+
</div>
|
|
166
|
+
<div class="h-2 rounded-full bg-ink-50 overflow-hidden">
|
|
167
|
+
<div class="h-full rounded-full bg-blue-500 transition-all duration-500" :style="{ width: (count / maxRel * 100) + '%' }"></div>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
<div v-if="topSources.length" class="mt-8">
|
|
175
|
+
<div class="flex items-center justify-between mb-3">
|
|
176
|
+
<h2 class="section-label">{{ t('dataset.sources') }}</h2>
|
|
177
|
+
<router-link :to="{ name: 'sources', params: { registerId: resolvedId } }" class="text-xs text-blue-600 hover:underline">
|
|
178
|
+
View all {{ statsData?.sourceCount }} →
|
|
179
|
+
</router-link>
|
|
180
|
+
</div>
|
|
181
|
+
<div class="card overflow-x-auto">
|
|
182
|
+
<table class="w-full text-sm">
|
|
183
|
+
<thead>
|
|
184
|
+
<tr class="border-b border-ink-100/60 bg-ink-50">
|
|
185
|
+
<th class="text-left px-4 py-2.5 text-ink-600 font-medium text-xs uppercase tracking-wide">Source</th>
|
|
186
|
+
<th class="text-left px-4 py-2.5 text-ink-600 font-medium text-xs uppercase tracking-wide">Type</th>
|
|
187
|
+
<th class="text-right px-4 py-2.5 text-ink-600 font-medium text-xs uppercase tracking-wide">Concepts</th>
|
|
188
|
+
</tr>
|
|
189
|
+
</thead>
|
|
190
|
+
<tbody>
|
|
191
|
+
<tr v-for="src in topSources" :key="src.ref" class="border-b border-ink-50 last:border-0">
|
|
192
|
+
<td class="px-4 py-2.5 font-medium text-ink-800">{{ src.ref }}</td>
|
|
193
|
+
<td class="px-4 py-2.5">
|
|
194
|
+
<span v-for="tp in src.types" :key="tp" class="badge text-[9px] mr-1" :class="sourceTypeBadge(tp)">{{ tp }}</span>
|
|
195
|
+
</td>
|
|
196
|
+
<td class="text-right px-4 py-2.5 font-mono text-ink-700">{{ src.conceptCount.toLocaleString() }}</td>
|
|
197
|
+
</tr>
|
|
198
|
+
</tbody>
|
|
199
|
+
</table>
|
|
200
|
+
</div>
|
|
142
201
|
</div>
|
|
143
202
|
</template>
|
|
144
203
|
</div>
|