umlimator 0.12.1 → 0.14.0

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 CHANGED
@@ -18,25 +18,41 @@ drill-down, a data pipeline, an AI agent's skills, and host pages for embedding,
18
18
  - **Unfolding in place**: a stacked card can also open into a box on the map it is on (E, or Alt + click), its inner
19
19
  view's cards inside and the lines to it moved onto them, the rest of the picture in its order.
20
20
  - **Layout and routing**: columns in reading order, each ordered so the lines cross as little as they can, those that
21
- skip columns included; each card level with the cards its lines come from, so lines run straight; orthogonal lines
22
- that never run through a card, room for every label, containers kept apart, and cards with no lines in a row of their
23
- own, under the view or at the bottom of their box. The same model gives the same picture.
21
+ skip columns included; each card level with the cards its lines come from, in its box or outside it, so lines run
22
+ straight; orthogonal lines that never run through a card, room for every label, containers kept apart, and cards with
23
+ no lines in a row of their own, under the view or at the bottom of their box. The same model gives the same picture.
24
24
  - **Reading**: animated flow, neighbours lit on selection, numbered steps, tours, minimap, light and dark themes.
25
25
  - **Exploring**: every path between two cards, or everything a card reaches, lit on the map; a view in plain sentences
26
26
  (`describe()`); a tour along any path the host names.
27
27
  - **Live data**: marks over the map without touching the model (`overlay()`): flow as fast as the traffic, errors that
28
- pulse, slow hops that glow, labels and tabs; and an OpenTelemetry trace replayed along its lines (`replay()`).
28
+ pulse, slow hops that glow, labels and tabs; and an OpenTelemetry trace replayed along its lines (`replay()`), or kept
29
+ as a numbered view to review in a pull request (`umlimator.py trace`).
30
+ - **Lenses**: saved ways to look at one map, by team or by data flow: what a lens picks lit and the rest dimmed, cards
31
+ ringed by owner with a legend, from the page's Lenses menu or a link. The view is never laid out again.
32
+ - **Budgets**: lines' latency and cost; the slowest or costliest path lit with its total (`critical()`), and what loses
33
+ its way if a card goes down (`whatIf()`).
29
34
  - **Stories**: walk-throughs in the model, step by step (view, camera, selection, highlight, tour and a caption), played
30
35
  on the map (`play()`) or from the page's Stories menu, recorded from the page, and written as an animated PNG.
31
36
  - **Keyboard and screen readers**: cards, then lines, in the Tab order (a card's tabs and chips too, for a host that
32
37
  listens for click); arrows between cards; selections announced.
38
+ - **The page's pieces, embedded**: the standalone page's views list, details panel, legend and hint line, each one a
39
+ `render()` option, around the map in the host's own page.
33
40
  - **Editing** (opt-in): gestures emit `change` events; the host applies them and calls `update()`.
34
- - **Export**: any view as a stand-alone SVG or PNG, in the browser or from the CLI.
41
+ - **Export**: any view as a stand-alone SVG or PNG, in the browser or from the CLI; the whole map as one PDF to hand in
42
+ for a review: a cover with the contents, each view with its legend and tables of its cards, lines and boxes, then the
43
+ rules (`umlimator.py pdf`).
44
+ - **In docs**: `<umlimator-map src="model.json">` in any page, and an `umlimator` fenced block in VitePress, Docusaurus,
45
+ MkDocs or any Markdown that allows HTML.
35
46
  - **A layout check**: crossings, shared lines, detours, labels on lines, text that overflows its card.
36
47
  - **Rules**: what the model says must hold ("apps never call a database directly", "no cycles between teams' boxes"),
37
48
  marked on the map where it breaks; `umlimator.py check` fails on a broken rule, so CI does.
38
49
  - **Change**: what a pull request changes, marked on the map and said in words (with a GitHub Action that posts it);
39
50
  a time-lapse of a model's history, where cards glide, fade in and fade out; and drift, the map against the traffic seen.
51
+ - **Import**: a Structurizr workspace, a C4-PlantUML or Mermaid diagram, or what `docker compose config` and `kubectl get`
52
+ print as JSON, read into a model (`umlimator.py import`), with what it can't read listed line by line.
53
+ - **An AI assistant on the map**: an MCP server (`umlimator.py mcp`) through which an assistant such as Claude Code reads
54
+ the model (a view in words, paths, what a card reaches, broken rules, problems, a diff) and drives its page in your
55
+ browser (a view, a highlight, a tour, a story).
40
56
 
41
57
  ## Install
42
58
 
@@ -44,7 +60,7 @@ drill-down, a data pipeline, an AI agent's skills, and host pages for embedding,
44
60
  npm install umlimator
45
61
  ```
46
62
 
47
- Or from GitHub, at a tag or any commit: `"umlimator": "github:zintaen/umlimator#v0.12.1"`. npm 12 refuses git
63
+ Or from GitHub, at a tag or any commit: `"umlimator": "github:zintaen/umlimator#v0.14.0"`. npm 12 refuses git
48
64
  dependencies unless the project opts in, so that also needs `allow-git=root` in the `.npmrc` next to `package.json`.
49
65
 
50
66
  ## Embed
@@ -85,15 +101,77 @@ function MapView({ model, onSelect }) {
85
101
  rule the model breaks, view by view.
86
102
  - `map.overlay(name, layer)`, `highlight(refs)`, `tour(path)` and `replay(spans)` put the host's marks, a tour and a
87
103
  trace on the map; `paths()`, `reach()` and `describe()` walk a view and put it in words, in Node too.
104
+ - `map.lens(id)` puts one of the model's lenses on the map. `critical(model, view, { from, to, by })` finds the slowest
105
+ or costliest path, `whatIf(model, view, { remove })` what loses its way when cards go down, and
106
+ `sequenceFromTrace(model, spans, { view, match })` keeps a trace as a numbered view; all three in Node too.
88
107
  - `editable: true` turns on editing gestures ([docs/editing.md](docs/editing.md); `examples/edit.html` is a working host).
89
108
  - `diff(before, after)` returns `after` with each card and line marked added, changed or removed, what changed in words,
90
109
  and the views that show it; `drift(model, observed)` marks the lines traffic ran on that nobody drew, and the drawn ones
91
110
  that carried nothing. Both are models to draw, in Node too. `update()` eases into the next model (`examples/timelapse.html`);
92
111
  the option `ease: false` draws it at once.
93
- - `mountPage` is the whole standalone viewer that the CLI writes.
112
+ - `mountPage` is the whole standalone viewer that the CLI writes. `render()` can draw its pieces too, each on its own or
113
+ any mix, inside the map's element; the host keeps its own header:
114
+
115
+ ```js
116
+ render(el, model, { nav: true, details: true, legend: true, hints: true });
117
+ ```
118
+
119
+ - `nav`: the views list left of the map, opened and shut by its ☰ button or O. Picking a view emits `viewchange`.
120
+ - `details`: the selected card's panel under the map, with its tabs (`[` and `]`). Its Open and Unfold here buttons
121
+ emit `open` and `expand`, as the canvas does, so a host can load a view first.
122
+ - `legend` and `hints`: the legend, and the line saying what the pointer does, over the map.
123
+
124
+ They share the page's code and look, take the `--um-*` colours (the theme adds `--um-mark` and `--um-scrim` for them),
125
+ and follow `update()` and `destroy()`. `examples/embed.html` uses them.
94
126
 
95
127
  The API, its types and the decisions behind it: [docs/module-api.md](docs/module-api.md) and `lib/umlimator.d.ts`.
96
128
 
129
+ ### In a page or a docs site, as an element
130
+
131
+ `<umlimator-map>` (`umlimator/element`) loads a model and draws it, with no script of your own. Its children show until
132
+ the map is drawn, and stay where scripts don't run ([`examples/element.html`](examples/element.html)):
133
+
134
+ ```html
135
+ <link rel="stylesheet" href="node_modules/umlimator/lib/umlimator-theme.css">
136
+ <script type="module" src="node_modules/umlimator/lib/umlimator-element.js"></script>
137
+
138
+ <umlimator-map src="bookshop.json" view="api" minimap height="520">
139
+ <img src="stills/api.png" alt="An order, step by step">
140
+ </umlimator-map>
141
+ ```
142
+
143
+ - **The model**: `src`, a URL resolved as an `<img>`'s is (a `data:` URL too), or the model in a child
144
+ `<script type="application/json">`.
145
+ - **Attributes**: `view`, `minimap`, `controls="false"`, `wheel="ctrl"` (the wheel zooms only with Ctrl or ⌘, and scrolls
146
+ the page otherwise, as a map in a document should), `height` (a number is pixels; 480 by default, or size it in CSS)
147
+ and `theme` (`light` or `dark`: theme.css's palette for that map, whatever the page's). Changing one draws the map
148
+ again, and removing the element destroys it.
149
+ - **Events**: `umlimator:select`, `umlimator:open` (cancel it to stay) and `umlimator:viewchange`, the map's event as
150
+ `detail`; `load` once it is drawn, `error` when it can't be. `el.map` is the `render()` handle.
151
+ - **Colours**: theme.css, as above, or your own `--um-*` variables. The element fetches nothing but its model.
152
+ - **Dark mode**: without `theme`, the map follows the page, a docs site's dark mode included: VitePress's
153
+ `<html class="dark">` and Docusaurus's `<html data-theme="dark">`.
154
+
155
+ In a docs site, a fenced block becomes the element. It names the model's `src`, with `view` or any other attribute, or
156
+ holds the model in JSON:
157
+
158
+ ````markdown
159
+ ```umlimator
160
+ src: /maps/bookshop.json
161
+ view: api
162
+ ```
163
+ ````
164
+
165
+ - **VitePress**: in `.vitepress/config`, `markdown: { config: md => md.use(umlimator) }` (`import umlimator from
166
+ 'umlimator/markdown-it'`) and `vue: { template: { compilerOptions: { isCustomElement: t => t === 'umlimator-map' } } }`;
167
+ in `.vitepress/theme/index`, `import 'umlimator/element'` and `import 'umlimator/theme.css'`.
168
+ - **Docusaurus**: `remarkPlugins: [umlimator]` (`import umlimator from 'umlimator/remark'`) in the docs preset's options,
169
+ and a client module (`clientModules: ['./src/umlimator.js']`) that imports `umlimator/element` and
170
+ `umlimator/theme.css`.
171
+ - **MkDocs, or any site that allows HTML**: write the element itself in the Markdown. Copy `lib/umlimator.js`,
172
+ `lib/umlimator-element.js` and `lib/umlimator-theme.css` into `docs/js/`, and load them in `mkdocs.yml`:
173
+ `extra_javascript: [{ path: js/umlimator-element.js, type: module }]` and `extra_css: [js/umlimator-theme.css]`.
174
+
97
175
  ## Model
98
176
 
99
177
  A model has shared `nodes` and `edges`, and `views` that each show a subset, with optional `containers` (boxes),
@@ -111,6 +189,7 @@ kinds such as `public`, `lane`, `script`, `hook` and `board` work too). Fields b
111
189
  | node | `icon` | a logo or line icon from `icons/manifest.json` |
112
190
  | node | `noSub` | the name only; the description stays in the details panel |
113
191
  | node | `opensView`, `opensCount` | the view the card opens, which may not be loaded yet, and the ⤢ number for it |
192
+ | node | `owner` | who owns the card (a team): rules and lenses pick by it, and a lens can ring cards by owner |
114
193
  | node, edge | `edited` | a marker on the card or line |
115
194
  | edge | `kind` | what the line means, drawn as its colour: `calls` (default), `feeds`, `triggers`, `state`, `guard` |
116
195
  | edge | `weight` | 1, 2 or 3: how much the line stands for, drawn as its width |
@@ -119,14 +198,16 @@ kinds such as `public`, `lane`, `script`, `hook` and `board` work too). Fields b
119
198
  | edge | `from`, `to` | may name a container of the view: the line ends on its box |
120
199
  | edge | `style: "faded"` | the line in its kind's colour, faint and with no flow (drift's idle lines) |
121
200
  | edge | `drift` | what `drift()` found: `undrawn` or `idle` |
201
+ | edge | `latency`, `cost` | budgets, in ms and in any unit: `critical()` sums them along a path (not drawn) |
122
202
  | view | `gap`, `rgap` | the space between columns and between rows |
123
203
  | view | `keepPairs` | A→B and B→A drawn as two lines |
124
204
  | view | `repair: false` | no port repair |
125
205
  | view | `subAndChips` | cards 80 px tall that show their sub-line, then their chips |
126
206
  | view | `kindsFromLabels` | a line whose edges name no `kind` takes one guessed from its label |
127
207
  | container | `badges` | tabs on the box's border |
128
- | model | `rules` | what must hold: no line `from` some cards `to` others (picked by id, kind or box), or `noCycles` between boxes |
208
+ | model | `rules` | what must hold: no line `from` some cards `to` others (picked by id, kind, box or owner), or `noCycles` between boxes |
129
209
  | model | `stories` | walk-throughs: steps that each set the view, camera, selection, highlight, tour and caption |
210
+ | model | `lenses` | ways to look at the map: what `pick` picks (by id, kind, box, owner or `line` kind) lit, the rest dimmed; `color: "owner"` rings cards by owner |
130
211
 
131
212
  ## Command line
132
213
 
@@ -141,15 +222,43 @@ python3 umlimator.py drift model.json traffic.json --out drift.html # drawn vs
141
222
  python3 umlimator.py timelapse model.json --out history.html # the model's versions in git history, on a slider
142
223
  python3 umlimator.py check page.html --expand # also each view with each stacked card unfolded
143
224
  python3 umlimator.py record bike-share.html --story ride --out ride.png # bike-share's story as an animated PNG, its tours riding
225
+ python3 umlimator.py import workspace.dsl --out model.json # a diagram you have already, as a model
226
+ docker compose config --format json > compose.json && python3 umlimator.py import compose.json --out model.json
227
+ kubectl get deploy,sts,svc,ing -A -o json > cluster.json && python3 umlimator.py import cluster.json --out model.json
228
+ python3 umlimator.py pdf page.html --out map.pdf # every view in one PDF: a cover, each view with its tables, the rules
229
+ python3 umlimator.py trace model.json trace.json --view ride-end --match match.json --out model.json # an OTLP trace kept as a numbered view
230
+ python3 umlimator.py mcp model.json # an MCP server over stdio, for an AI assistant (below)
144
231
  ```
145
232
 
146
233
  `check` also reports each rule the model breaks, and then exits 1.
147
234
 
148
- The page has breadcrumbs, a details panel per card, ⌘K search, deep links (`#view=<id>&node=<id>`, and
149
- `#story=<id>&step=<n>` for a story's step), stories, and export.
150
- `stills` skips a view whose inputs have not changed since its last export (`--all` exports every view). Needs Python
151
- 3.9+ (standard library only), Google Chrome or Chromium for `stills`, `check` and `record`, and Node 18+ for `diff`
152
- and `drift`.
235
+ `import` reads Structurizr DSL (people, systems, containers and components, their relationships, and the workspace's
236
+ views), C4-PlantUML (its element, relationship and boundary macros), Mermaid (`flowchart` or `graph`, and `C4Context`,
237
+ `C4Container` or `C4Component`), and the JSON of `docker compose config` (services, `depends_on`, networks) and of
238
+ `kubectl get` (workloads, Services, Ingresses). It tells the format from the content, then the extension; `--format`
239
+ decides. Groups, boundaries, subgraphs, networks and namespaces become boxes; Structurizr's views stay views, a card
240
+ opening the view below it; `examples/clinic.json` is `examples/clinic.dsl`, imported. What each element becomes, and
241
+ what is left out: [docs/module-api.md § Import](docs/module-api.md#import-90). Whatever the file holds that `import`
242
+ doesn't read is listed on stderr with its line (in JSON, its path).
243
+
244
+ `pdf` prints the map through Chrome's own print, as a document to hand in for a review
245
+ ([bike-share's](https://zintaen.github.io/umlimator/examples/bike-share.pdf), as CI builds it). A cover has the title,
246
+ the date, the model's file, a summary line and the contents with each view's page. Each view starts a page of its own,
247
+ under its path ("Bike share › What runs"): its picture in vectors, as large as the page allows, the legend of what it
248
+ draws, and tables of its cards, lines and boxes, which go on over the next pages with their header row when they are
249
+ long. The rules follow as a table, when the model has any, each holding or broken in the views printed. Every page ends in
250
+ the title and "page N of M". It takes `--views` and `--theme`, as `stills` does, and `--paper a4|letter` (A4 is the
251
+ default; both across).
252
+
253
+ The page has breadcrumbs, a details panel per card (with the slowest path from it and what-if), ⌘K search, deep links
254
+ (`#view=<id>&node=<id>`, `#story=<id>&step=<n>` for a story's step, and `#lens=<id>`), stories, lenses, and export. For
255
+ scripts and tests, it keeps its map's handle on `window.umlimatorMap`: the `Handle` that `render()` returns (`setView()`,
256
+ `highlight()`, `tour()`, `play()` ...).
257
+ `stills` skips a view whose inputs have not changed since its last export (`--all` exports every view), and exits 1,
258
+ saying why, when a view can't be exported. In `stills`, `check` and `pdf`, `--views` takes view ids, and a trailing `*`
259
+ is a prefix; a name that matches no view is an error. A command that fails leaves its previous output as it was. Needs Python
260
+ 3.9+ (standard library only), Google Chrome or Chromium for `stills`, `check`, `record` and `pdf`, and Node 18+ for
261
+ `diff`, `drift`, `trace` and `mcp`.
153
262
 
154
263
  `record` writes an animated PNG, which browsers and GitHub show as it is. For a GIF or an MP4 (for chat or slides),
155
264
  convert it with ffmpeg:
@@ -160,21 +269,57 @@ ffmpeg -f apng -i ride.png -vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" -pix_fmt yuv4
160
269
  ```
161
270
 
162
271
  On pull requests, the diff Action posts what they change in your maps: one comment with the summary and a sketch, and
163
- the pictures as the run's artifact.
272
+ the pictures as the run's artifact. The comment names the commit it describes, only the run for the pull request's
273
+ latest commit writes it, and it says so when the diff fails.
164
274
 
165
275
  ```yaml
166
276
  on: pull_request
167
277
  permissions: { contents: read, pull-requests: write }
278
+ concurrency: { group: "maps-${{ github.event.pull_request.number }}", cancel-in-progress: true } # one run per pull request
168
279
  jobs:
169
280
  maps:
170
281
  runs-on: ubuntu-latest
171
282
  steps:
172
283
  - uses: actions/checkout@v4
173
- - uses: zintaen/umlimator@v0.12.1
284
+ - uses: zintaen/umlimator@v0.14.0
174
285
  with:
175
286
  models: "docs/*.json" # optional: where the models are (default: every .json that is a map model)
176
287
  ```
177
288
 
289
+ ## An AI assistant on the map (MCP)
290
+
291
+ `umlimator.py mcp model.json` is a [Model Context Protocol](https://modelcontextprotocol.io) server over stdio: an AI
292
+ assistant reads the model, and shows you what it finds on the map in your browser. Add it to Claude Code:
293
+
294
+ ```bash
295
+ claude mcp add umlimator -- python3 /path/to/umlimator.py mcp /path/to/model.json
296
+ ```
297
+
298
+ | Tool | What it does |
299
+ |---|---|
300
+ | `list_views()` | the views (id, title, parent, number of cards) and the stories |
301
+ | `describe_view(view)` | the view in sentences (`describe()`), then each card's id |
302
+ | `paths(view, from, to)` | every card and line on a path from one card to another (`paths()`), as Refs for `highlight` |
303
+ | `reach(view, card, backward?, depth?)` | what a card leads to, or what leads to it, with the distance of each (`reach()`) |
304
+ | `broken_rules()`, `validate()` | each break of the model's `rules` (`rules()`), and its own problems (`validate()`) |
305
+ | `diff(other_model_path)` | what changed from another model file to this one, in words, and the views that show it (`diff()`) |
306
+ | `show_view(view)` | shows a view on the page |
307
+ | `highlight(refs, view?)` | lights cards and lines (their ids, or the Refs `paths` and `reach` give) and dims the rest |
308
+ | `tour(cards, view?)` | a dot rides the lines through these cards |
309
+ | `play_story(id)`, `clear()` | plays one of the model's stories; takes the assistant's highlight, tour and story off |
310
+ | `page_state()` | what each open page shows (its map's `state`: view, selection, layers, story, camera), and whether the model renders |
311
+
312
+ - The read tools run the module's own functions in Node on the model file, read again at each call, so edits show up.
313
+ - The page tools drive the map's page, which the server serves at `http://127.0.0.1:7391` (`--port N`, or another port
314
+ when that one is taken). Open it in a browser.
315
+ - A tool answers "shown" once a page has shown it, or says why none did: the page's own error, or a tab in the
316
+ background that didn't finish in time. With no page open, it gives the URL.
317
+ - A page that opens later shows what the assistant built: the view, with the highlight on it, and a tour or story
318
+ still under way.
319
+ - The assistant's highlight is a layer of its own (`mcp`): off on another view, back on its own.
320
+ - A model that stops rendering keeps the last page that did, and the tools and the page say why.
321
+ - The server listens on 127.0.0.1 only and fetches nothing, so a logo not in the cache yet keeps its card's sigil.
322
+
178
323
  ## Map a repo with Claude Code
179
324
 
180
325
  The `map-repo` skill reads a codebase, writes a C4-style model (context, containers, components), renders it to a page
@@ -203,11 +348,13 @@ python3 -I tools/fetch_icons.py icons "name:lucide/<icon>" # a line icon
203
348
  python3 umlimator.py pin-icons # records each new logo's sha256
204
349
  ```
205
350
 
351
+ With `UMLIMATOR_OFFLINE=1` the CLI fetches nothing: a logo not in the cache keeps its card's sigil (`mcp` sets it).
352
+
206
353
  ## Development
207
354
 
208
355
  ```bash
209
356
  node --test # layout() in Node: the bench views' geometry and the layout rules
210
- python3 tests/embed.py # render() in headless Chrome: events, keyboard, colours, contrast, editing
357
+ python3 tests/embed.py # render() in headless Chrome: events, keyboard, colours, contrast, editing, the element
211
358
  python3 bench/bench.py --check-determinism --expect bench/expected.json --max-ms m50=2000
212
359
  ```
213
360
 
@@ -223,9 +370,11 @@ Release with that section as its notes, and publishes the package to npm (truste
223
370
  CI also publishes the examples to GitHub Pages from `main`.
224
371
 
225
372
  ```
226
- umlimator.py CLI: render, stills, check, record, pin-icons, diff, drift, timelapse
373
+ umlimator.py CLI: render, stills, check, record, pdf, pin-icons, diff, drift, timelapse, import, trace, mcp
374
+ umlimator_import.py import's readers: Structurizr DSL, C4-PlantUML, Mermaid, compose and Kubernetes JSON
375
+ umlimator_mcp.py the MCP server that `umlimator.py mcp` runs
227
376
  action.yml the diff Action: what a pull request changes in its maps, posted on it
228
- lib/ the module (umlimator.js), its types and the default theme
377
+ lib/ the module (umlimator.js), its types, the default theme, <umlimator-map> and the docs plugins
229
378
  engine/ the page templates the CLI inlines the module into; the upstream renderer and schema
230
379
  docs/ the module's API and the editing design
231
380
  tests/, bench/ tests, benchmark models and results
@@ -0,0 +1,21 @@
1
+ // Types for lib/umlimator-element.js, `umlimator/element` (#92). Its header says what each attribute and event does.
2
+ import type { Events, Handle } from './umlimator.js';
3
+
4
+ /** <umlimator-map src view minimap controls wheel theme height>: the model from `src` (a URL) or a child
5
+ * <script type="application/json">, drawn by render(); its other children show until the map is drawn. */
6
+ export class UmlimatorMap extends HTMLElement {
7
+ /** render()'s handle once the map is drawn (each `load` event), else null. Its `state` is the map's MapState. */
8
+ readonly map: Handle | null;
9
+ /** True while a draw waits for its model (0.14.0); `map` is then still the map drawn last. `load` or `error` ends it. */
10
+ readonly loading: boolean;
11
+ }
12
+
13
+ declare global {
14
+ interface HTMLElementTagNameMap { 'umlimator-map': UmlimatorMap }
15
+ /** The map's events, re-dispatched on the element (they bubble); preventDefault() on umlimator:open keeps the view. */
16
+ interface HTMLElementEventMap {
17
+ 'umlimator:select': CustomEvent<Events['select']>;
18
+ 'umlimator:open': CustomEvent<Events['open']>;
19
+ 'umlimator:viewchange': CustomEvent<Events['viewchange']>;
20
+ }
21
+ }
@@ -0,0 +1,89 @@
1
+ /* <umlimator-map> (#92): a map in any page from HTML alone, as `umlimator/element`.
2
+
3
+ <umlimator-map src="model.json" view="system" minimap height="560">
4
+ <img src="system.png" alt="The system view">
5
+ </umlimator-map>
6
+
7
+ The model comes from `src`, a URL (a data: URL too), or from a child <script type="application/json">, and render()
8
+ draws it in the element's shadow root. The element's other children (a still, say) show until the map is drawn, and stay
9
+ where scripts don't run. Changing src, view, minimap, controls (controls="false" hides them) or wheel draws it again,
10
+ and removing the element destroys the map. wheel="ctrl": the wheel zooms only with Ctrl or ⌘, and scrolls the page
11
+ otherwise, as a map in a document should. height: a number is pixels (default 480px; CSS may size it instead). theme:
12
+ "light" or "dark", that map's palette from lib/umlimator-theme.css, whatever the page's; without it the map follows the
13
+ page, a docs site's dark mode too (VitePress's html.dark, Docusaurus's html[data-theme="dark"]). The colours come from
14
+ that file or the host's own --um-* variables; the element fetches nothing but its model.
15
+ Events: the map's select, open and viewchange as umlimator:select, umlimator:open and umlimator:viewchange, the map's
16
+ event as `detail` (preventDefault() on umlimator:open keeps the view); load each time the map is drawn, error (an
17
+ ErrorEvent, whose message says why) when it can't be, a model with no view to draw too (its children show again).
18
+ `el.map` is render()'s handle once drawn (toSVG(), highlight(), setView(), state ...), else null; `el.loading` is true
19
+ while a draw waits for its model, when el.map is still the map drawn last. An attribute set to the value it has draws
20
+ nothing again. */
21
+ import { render } from './umlimator.js';
22
+
23
+ const Base = globalThis.HTMLElement || class {}; // importable where there is no DOM (a docs site's server render)
24
+
25
+ export class UmlimatorMap extends Base {
26
+ static observedAttributes = ['src', 'view', 'minimap', 'controls', 'wheel', 'height'];
27
+ map = null;
28
+ #drawn = 0; #due = false; #src = null; #model = null; #loading = false;
29
+
30
+ constructor() {
31
+ super();
32
+ this.attachShadow({ mode: 'open' }).innerHTML = '<style>:host { display: block; height: 480px; overflow: hidden; } ::slotted(img) { display: block; max-width: 100%; max-height: 100%; }</style>'
33
+ + '<slot></slot><div hidden style="height: 100%"></div>';
34
+ }
35
+ get loading() { return this.#loading; } // a draw under way, waiting for its model (0.14.0)
36
+ connectedCallback() { this.#soon(); }
37
+ disconnectedCallback() { this.#loading = false; this.#drop(); }
38
+ attributeChangedCallback(name, was, now) {
39
+ if (was === now) return; // set to the value it has: nothing to draw again (0.14.0)
40
+ if (name === 'height') this.style.height = now == null ? '' : /^\d+(\.\d+)?$/.test(now) ? now + 'px' : now;
41
+ else this.#soon();
42
+ }
43
+ #soon() { // attributes set together draw once
44
+ this.#loading = true;
45
+ if (this.#due) return;
46
+ this.#due = true;
47
+ queueMicrotask(() => { this.#due = false; if (this.isConnected) this.#draw(); else this.#loading = false; });
48
+ }
49
+ #drop() {
50
+ this.map?.destroy(); this.map = null;
51
+ const [, slot, box] = this.shadowRoot.children; slot.hidden = false; box.hidden = true;
52
+ }
53
+ #fail(err) { // the children show again, and the error event says why
54
+ this.#loading = false; this.#drop(); console.warn('<umlimator-map>:', err);
55
+ this.dispatchEvent(new ErrorEvent('error', { message: String((err && err.message) || err), error: err }));
56
+ }
57
+ async #draw() {
58
+ const n = ++this.#drawn, src = this.getAttribute('src'), get = a => this.getAttribute(a); // a later draw wins
59
+ let model = src && src === this.#src ? this.#model : null;
60
+ try {
61
+ if (src && !model) {
62
+ const r = await fetch(src);
63
+ if (!r.ok) throw new Error(`${src}: ${r.status} ${r.statusText}`);
64
+ model = await r.json();
65
+ } else if (!src) {
66
+ const json = this.querySelector(':scope > script[type="application/json"]');
67
+ if (!json) throw new Error('no src, and no <script type="application/json"> child');
68
+ model = JSON.parse(json.textContent);
69
+ }
70
+ } catch (err) { if (n === this.#drawn) this.#fail(err); return; }
71
+ if (n !== this.#drawn) return; // a later draw has the element
72
+ if (!this.isConnected) { this.#loading = false; return; }
73
+ if (src) { this.#src = src; this.#model = model; }
74
+ this.#drop();
75
+ const [, slot, box] = this.shadowRoot.children; slot.hidden = true; box.hidden = false;
76
+ // the map's events, as DOM events on the element; a host that cancels umlimator:open cancels the open
77
+ const on = Object.fromEntries(['select', 'open', 'viewchange'].map(t => [t, e => {
78
+ if (!this.dispatchEvent(new CustomEvent('umlimator:' + t, { detail: e, bubbles: true, composed: true, cancelable: t === 'open' }))) e.preventDefault();
79
+ }]));
80
+ try {
81
+ this.map = render(box, model, { view: get('view') || undefined, minimap: this.hasAttribute('minimap') && get('minimap') !== 'false', controls: get('controls') !== 'false', wheel: get('wheel') === 'ctrl' ? 'ctrl' : 'zoom', on });
82
+ } catch (err) { return this.#fail(err); }
83
+ if (this.map.view == null) return this.#fail(new Error(`${src || 'the inline model'}: the model has no view to draw`)); // drawn, with nothing to see: an error, not a load (0.14.0)
84
+ this.#loading = false;
85
+ this.dispatchEvent(new Event('load'));
86
+ }
87
+ }
88
+
89
+ if (globalThis.customElements && !customElements.get('umlimator-map')) customElements.define('umlimator-map', UmlimatorMap);
@@ -0,0 +1,6 @@
1
+ // Types for lib/umlimator-markdown-it.js, `umlimator/markdown-it` (#92).
2
+ /** A markdown-it plugin: each ```umlimator fenced block becomes a <umlimator-map> element. md.use(umlimator) */
3
+ export default function umlimator(md: any): void;
4
+ /** A block's text as the element's attributes (src, view, minimap, controls, theme, height), an inline model as a data:
5
+ * URL in src. Throws on a block the element can't use. */
6
+ export function attrs(text: string): Record<string, string>;
@@ -0,0 +1,33 @@
1
+ /* A markdown-it plugin (VitePress, or any markdown-it), as `umlimator/markdown-it` (#92): a ```umlimator fenced block
2
+ becomes a <umlimator-map> element, which `umlimator/element` draws in the browser. md.use(umlimator)
3
+
4
+ ```umlimator
5
+ src: /maps/shop.json
6
+ view: api
7
+ ```
8
+
9
+ The block's `key: value` lines are the element's attributes (src, view, minimap, controls, wheel, theme, height); after
10
+ them, or instead of src, it may hold a model in JSON. An inline model goes into src as a data: URL, since VitePress
11
+ compiles the page as a Vue template, which drops a <script> child. A block the element can't use fails the build,
12
+ where it is seen. */
13
+ const KEYS = ['src', 'view', 'minimap', 'controls', 'wheel', 'theme', 'height'];
14
+
15
+ /** A block's text as the element's attributes. */
16
+ export function attrs(text) {
17
+ const at = text.search(/^\s*\{/m), out = {};
18
+ for (const line of (at < 0 ? text : text.slice(0, at)).split('\n').filter(l => l.trim())) {
19
+ const m = line.match(/^\s*([\w-]+)\s*:\s*(.*?)\s*$/);
20
+ if (!m || !KEYS.includes(m[1])) throw new Error(`umlimator block: "${line.trim()}" is not one of ${KEYS.map(k => k + ':').join(' ')} or a model in JSON`);
21
+ out[m[1]] = m[2];
22
+ }
23
+ if ((at >= 0) === !!out.src) throw new Error('umlimator block: give either src: (a model\'s URL) or a model in JSON');
24
+ if (at >= 0) out.src = 'data:application/json,' + encodeURIComponent(JSON.stringify(JSON.parse(text.slice(at)))); // minified; a broken model throws
25
+ return out;
26
+ }
27
+
28
+ const html = text => `<umlimator-map${Object.entries(attrs(text)).map(([k, v]) => ` ${k}="${v.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')}"`).join('')}></umlimator-map>\n`;
29
+
30
+ export default function umlimator(md) {
31
+ const fence = md.renderer.rules.fence;
32
+ md.renderer.rules.fence = (tokens, i, ...rest) => tokens[i].info.trim().split(/\s+/)[0] === 'umlimator' ? html(tokens[i].content) : fence(tokens, i, ...rest);
33
+ }
@@ -0,0 +1,4 @@
1
+ // Types for lib/umlimator-remark.js, `umlimator/remark` (#92).
2
+ /** A remark plugin: each ```umlimator fenced block becomes a <umlimator-map> element (a node with data.hName, which MDX
3
+ * and remark-rehype draw as it). remarkPlugins: [umlimator] */
4
+ export default function umlimator(): (tree: any) => void;
@@ -0,0 +1,13 @@
1
+ /* A remark plugin (Docusaurus, MDX, or any remark-rehype pipeline), as `umlimator/remark` (#92): a ```umlimator fenced
2
+ block becomes a <umlimator-map> element, with the attributes umlimator/markdown-it gives it (its header says what a
3
+ block holds). The block's node becomes one that names the element (data.hName, data.hProperties), which MDX and
4
+ remark-rehype both turn into it. remarkPlugins: [umlimator] */
5
+ import { attrs } from './umlimator-markdown-it.js';
6
+
7
+ export default function umlimator() {
8
+ const walk = node => node.children?.forEach((c, i) => {
9
+ if (c.type === 'code' && c.lang === 'umlimator') node.children[i] = { type: 'umlimator', data: { hName: 'umlimator-map', hProperties: attrs(c.value) }, children: [] };
10
+ else walk(c);
11
+ });
12
+ return walk;
13
+ }
@@ -1,4 +1,6 @@
1
- /* umlimator's default palette and fonts: light, and dark when the system asks or <html data-theme="dark">.
1
+ /* umlimator's default palette and fonts: light, and dark when the system asks or <html data-theme="dark">; a
2
+ <umlimator-map theme="light|dark"> takes that palette whatever the page's, and one without a theme is dark in a docs
3
+ site's dark mode too (VitePress's <html class="dark">, Docusaurus's <html data-theme="dark">).
2
4
  The canvas reads only these --um-* variables (it bakes in no colour and loads no font), so a host may use this file,
3
5
  set the variables itself (e.g. --um-bg: var(--surface)), or switch light and dark by changing them on any ancestor
4
6
  of the map. Fonts are the host's: --um-sans and --um-mono name families the page already has; nothing is fetched.
@@ -6,7 +8,7 @@
6
8
  The tints (--um-<tone>-f) below are for page chrome: the map mixes its own from the tones and --um-panel in effect
7
9
  where it is, so setting a tone on any ancestor of the map recolours its tint too. */
8
10
  /* Clean flowchart (GitHub Primer-like): light first, dark when the system or the toggle asks. */
9
- :root {
11
+ :root, umlimator-map[theme="light"] {
10
12
  --um-stage-bg: #fbfcfd; --um-logo-tile: #ffffff; --um-logo-tile-dark: #1b1f23; --um-intake-mark: #1b1f23; --um-edited: #bf8700;
11
13
  --um-bg: #ffffff; --um-bg-2: #f6f8fa; --um-panel: #ffffff; --um-panel-2: #f6f8fa; --um-border: #d8dee4; --um-border-2: #d1d9e0;
12
14
  --um-text: #1f2328; --um-muted: #59636e; --um-dim: #818b98; --um-grid: rgba(31,35,40,.06); --um-mask: #ffffff; --um-arrow: #8c959f;
@@ -16,6 +18,7 @@
16
18
  --um-k-public: #0969da; --um-k-lane: #0f766e; --um-k-ext: #8250df; --um-k-verb: #bf3989; --um-k-script: #6e7781; --um-k-hook: #cf222e; --um-k-board: #9a6700;
17
19
  --um-w-calls: #424a53; --um-w-feeds: #0969da; --um-w-triggers: #8250df; --um-w-state: #868f99; --um-w-guard: #cf222e;
18
20
  --um-add-bg: rgba(26,127,55,.10); --um-add-tx: #1a7f37; --um-del-bg: rgba(207,34,46,.08); --um-del-tx: #cf222e;
21
+ --um-mark: #fff1a6; --um-scrim: rgba(15,18,22,.72); /* the details panel's ==highlighter==, and the dark behind an example shown full size */
19
22
  color-scheme: light;
20
23
  --um-accent-f: color-mix(in srgb, var(--um-accent) 12%, var(--um-panel));
21
24
  --um-cyan-f: color-mix(in srgb, var(--um-cyan) 9%, var(--um-panel));
@@ -46,11 +49,12 @@
46
49
  --um-cyan: #4493f8; --um-green: #3fb950; --um-violet: #ab7df8; --um-amber: #d29922; --um-rose: #f85149; --um-orange: #db6d28; --um-slate: #9198a1; --um-fuchsia: #db61a2;
47
50
  --um-k-public: #4493f8; --um-k-lane: #2dd4bf; --um-k-ext: #ab7df8; --um-k-verb: #f778ba; --um-k-script: #9198a1; --um-k-hook: #f85149; --um-k-board: #d29922;
48
51
  --um-w-calls: #b7bdc8; --um-w-feeds: #4493f8; --um-w-triggers: #ab7df8; --um-w-state: #59626e; --um-w-guard: #f85149;
49
- --um-add-bg: rgba(63,185,80,.13); --um-add-tx: #56d364; --um-del-bg: rgba(248,81,73,.12); --um-del-tx: #ff7b72;
52
+ --um-add-bg: rgba(63,185,80,.13); --um-add-tx: #56d364; --um-del-bg: rgba(248,81,73,.12); --um-del-tx: #ff7b72; --um-mark: rgba(250,204,21,.32);
50
53
  color-scheme: dark;
51
54
  }
52
55
  }
53
- :root[data-theme="dark"] {
56
+ /* the docs sites' dark mode: :where() weighs nothing, so a host's own --um-* on the element still win */
57
+ :root[data-theme="dark"], umlimator-map[theme="dark"], :where(html.dark, html[data-theme="dark"]) :where(umlimator-map:not([theme])) {
54
58
  --um-stage-bg: var(--um-bg-2); --um-edited: #d29922;
55
59
  --um-bg: #0d1117; --um-bg-2: #010409; --um-panel: #0d1117; --um-panel-2: #151b23; --um-border: #262c36; --um-border-2: #3d444d;
56
60
  --um-text: #f0f6fc; --um-muted: #9198a1; --um-dim: #656c76; --um-grid: rgba(240,246,252,.05); --um-mask: #0d1117; --um-arrow: #656c76;
@@ -59,6 +63,6 @@
59
63
  --um-cyan: #4493f8; --um-green: #3fb950; --um-violet: #ab7df8; --um-amber: #d29922; --um-rose: #f85149; --um-orange: #db6d28; --um-slate: #9198a1; --um-fuchsia: #db61a2;
60
64
  --um-k-public: #4493f8; --um-k-lane: #2dd4bf; --um-k-ext: #ab7df8; --um-k-verb: #f778ba; --um-k-script: #9198a1; --um-k-hook: #f85149; --um-k-board: #d29922;
61
65
  --um-w-calls: #b7bdc8; --um-w-feeds: #4493f8; --um-w-triggers: #ab7df8; --um-w-state: #59626e; --um-w-guard: #f85149;
62
- --um-add-bg: rgba(63,185,80,.13); --um-add-tx: #56d364; --um-del-bg: rgba(248,81,73,.12); --um-del-tx: #ff7b72;
66
+ --um-add-bg: rgba(63,185,80,.13); --um-add-tx: #56d364; --um-del-bg: rgba(248,81,73,.12); --um-del-tx: #ff7b72; --um-mark: rgba(250,204,21,.32);
63
67
  color-scheme: dark;
64
68
  }