@kekonic/diagrams-element 1.0.0-rc.4

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/dist/index.mjs ADDED
@@ -0,0 +1,1097 @@
1
+ import { LitElement, css, html, nothing, svg } from "lit";
2
+ //#region src/k-diagram.ts
3
+ const boolFromAttribute = {
4
+ fromAttribute: (value) => value !== "false",
5
+ toAttribute: (value) => value ? "" : "false"
6
+ };
7
+ function readSiteTheme() {
8
+ if (typeof document === "undefined") return "dark";
9
+ return document.documentElement.dataset.theme === "light" ? "light" : "dark";
10
+ }
11
+ function resolveTheme(theme) {
12
+ if (theme === "auto") return readSiteTheme();
13
+ return theme;
14
+ }
15
+ function formatMs(ms) {
16
+ return ms >= 100 ? `${Math.round(ms)}ms` : `${ms.toFixed(1)}ms`;
17
+ }
18
+ function cssHeight(height) {
19
+ return typeof height === "number" ? `${height}px` : height;
20
+ }
21
+ const CONTROLS_IDLE_MS = 2400;
22
+ const CONTROLS_LEAVE_MS = 400;
23
+ /**
24
+ * Interactive Kekonic Diagrams diagram as a Lit custom element.
25
+ * Wraps `KDiagram.renderToElement` — pan/zoom, theme, live `source` updates.
26
+ */
27
+ var KDiagramElement = class extends LitElement {
28
+ constructor(..._args) {
29
+ super(..._args);
30
+ this.source = "";
31
+ this.theme = "auto";
32
+ this.height = 420;
33
+ this.frameless = false;
34
+ this.showThemeToggle = true;
35
+ this.showViewControls = true;
36
+ this.showStats = false;
37
+ this.showAnimationControls = true;
38
+ this.autoplay = false;
39
+ this.animationLoop = false;
40
+ this.animation = "";
41
+ this.#controller = null;
42
+ this.#mountGen = 0;
43
+ this.#appliedTheme = null;
44
+ this.#themeObserver = null;
45
+ this.#activeTheme = "dark";
46
+ this.#ready = false;
47
+ this.#busy = false;
48
+ this.#error = null;
49
+ this.#stats = null;
50
+ this.#isFullscreen = false;
51
+ this.#controlsVisible = true;
52
+ this.#controlsIdleTimer = null;
53
+ this.#animList = [];
54
+ this.#animState = {
55
+ id: null,
56
+ playing: false,
57
+ timeMs: 0,
58
+ durationMs: 0,
59
+ loop: false,
60
+ speed: 1
61
+ };
62
+ this.#animUnsub = null;
63
+ this.#onFullscreenChange = () => {
64
+ this.#isFullscreen = document.fullscreenElement != null && document.fullscreenElement === this;
65
+ this.requestUpdate();
66
+ requestAnimationFrame(() => {
67
+ requestAnimationFrame(() => this.#controller?.fit());
68
+ });
69
+ };
70
+ this.#onAnimKeydown = (e) => {
71
+ if (!this.showAnimationControls || this.#animList.length === 0) return;
72
+ const anim = this.#controller?.animations;
73
+ if (!anim) return;
74
+ if (e.key === " " || e.code === "Space") {
75
+ e.preventDefault();
76
+ if (this.#animState.playing) anim.pause();
77
+ else anim.play();
78
+ } else if (e.key === "ArrowRight") {
79
+ e.preventDefault();
80
+ anim.step(1);
81
+ } else if (e.key === "ArrowLeft") {
82
+ e.preventDefault();
83
+ anim.step(-1);
84
+ }
85
+ };
86
+ this.#onStagePointerActivity = () => {
87
+ this.#revealControls();
88
+ };
89
+ this.#onStagePointerLeave = () => {
90
+ this.#scheduleControlsHide(CONTROLS_LEAVE_MS);
91
+ };
92
+ this.#onStageFocusIn = (event) => {
93
+ this.#revealControls(event.target !== event.currentTarget);
94
+ };
95
+ this.#onStageFocusOut = () => {
96
+ queueMicrotask(() => {
97
+ const root = this.renderRoot;
98
+ const focused = root instanceof ShadowRoot ? root.activeElement : null;
99
+ if (focused instanceof Element && focused.closest(".overlay")) return;
100
+ this.#scheduleControlsHide(CONTROLS_LEAVE_MS);
101
+ });
102
+ };
103
+ this.#onStageKeydown = (event) => {
104
+ this.#revealControls(event.target !== event.currentTarget);
105
+ this.#onAnimKeydown(event);
106
+ };
107
+ this.#onStagePointerDown = (e) => {
108
+ this.#revealControls();
109
+ if (e.composedPath().some((n) => n instanceof Element && (n.classList.contains("overlay--animation") || n.classList.contains("overlay--tools") || n.classList.contains("overlay--stats")))) return;
110
+ const root = this.renderRoot;
111
+ const focused = root instanceof ShadowRoot ? root.activeElement : null;
112
+ if (focused instanceof HTMLElement) focused.blur();
113
+ const stage = e.currentTarget;
114
+ if (stage instanceof HTMLElement) stage.focus({ preventScroll: true });
115
+ };
116
+ }
117
+ static {
118
+ this.properties = {
119
+ source: { type: String },
120
+ theme: { type: String },
121
+ height: {},
122
+ frameless: {
123
+ type: Boolean,
124
+ converter: boolFromAttribute,
125
+ reflect: true
126
+ },
127
+ showThemeToggle: {
128
+ type: Boolean,
129
+ attribute: "show-theme-toggle",
130
+ converter: boolFromAttribute
131
+ },
132
+ showViewControls: {
133
+ type: Boolean,
134
+ attribute: "show-view-controls",
135
+ converter: boolFromAttribute
136
+ },
137
+ showStats: {
138
+ type: Boolean,
139
+ attribute: "show-stats",
140
+ converter: boolFromAttribute
141
+ },
142
+ showAnimationControls: {
143
+ type: Boolean,
144
+ attribute: "animation-controls",
145
+ converter: boolFromAttribute
146
+ },
147
+ autoplay: {
148
+ type: Boolean,
149
+ converter: boolFromAttribute
150
+ },
151
+ animationLoop: {
152
+ type: Boolean,
153
+ attribute: "loop",
154
+ converter: boolFromAttribute
155
+ },
156
+ animation: { type: String },
157
+ options: { attribute: false }
158
+ };
159
+ }
160
+ static {
161
+ this.styles = css`
162
+ :host {
163
+ display: flex;
164
+ flex-direction: column;
165
+ position: relative;
166
+ box-sizing: border-box;
167
+ width: 100%;
168
+ height: 100%;
169
+ min-height: 0;
170
+ border: 1px solid var(--border, color-mix(in oklch, currentColor 18%, transparent));
171
+ background: var(--bg-panel, var(--bg, transparent));
172
+ color: var(--text, inherit);
173
+ overflow: hidden;
174
+ font-family: var(--font-ui, inherit);
175
+ }
176
+
177
+ :host([hidden]) {
178
+ display: none !important;
179
+ }
180
+
181
+ :host(:fullscreen) {
182
+ width: 100%;
183
+ height: 100% !important;
184
+ background: var(--bg, #0b0f14);
185
+ border: 0;
186
+ }
187
+
188
+ :host([frameless]:not([frameless="false"]):not(:fullscreen)) {
189
+ border: 0;
190
+ background: transparent;
191
+ }
192
+
193
+ :host([frameless]:not([frameless="false"]):not(:fullscreen)) .viewport {
194
+ background: transparent;
195
+ }
196
+
197
+ .sr-only {
198
+ position: absolute;
199
+ width: 1px;
200
+ height: 1px;
201
+ padding: 0;
202
+ margin: -1px;
203
+ overflow: hidden;
204
+ clip: rect(0, 0, 0, 0);
205
+ white-space: nowrap;
206
+ border: 0;
207
+ }
208
+
209
+ .error {
210
+ margin: 0;
211
+ padding: 0.55rem 0.75rem;
212
+ border-bottom: 1px solid
213
+ var(--border-soft, color-mix(in oklch, currentColor 12%, transparent));
214
+ color: var(--danger, #f87171);
215
+ font-size: 0.85rem;
216
+ }
217
+
218
+ .stage {
219
+ position: relative;
220
+ display: flex;
221
+ flex-direction: column;
222
+ flex: 1 1 0;
223
+ min-height: 0;
224
+ width: 100%;
225
+ height: 100%;
226
+ }
227
+
228
+ .stage:focus {
229
+ outline: none;
230
+ }
231
+
232
+ .viewport {
233
+ position: relative;
234
+ flex: 1 1 0;
235
+ width: 100%;
236
+ min-height: 0;
237
+ background: var(--bg, transparent);
238
+ z-index: 0;
239
+ }
240
+
241
+ .viewport .kdiagram-viewport,
242
+ .viewport .kdiagram-canvas,
243
+ .viewport svg {
244
+ width: 100%;
245
+ height: 100%;
246
+ display: block;
247
+ }
248
+
249
+ .overlay {
250
+ position: absolute;
251
+ z-index: 2;
252
+ display: inline-flex;
253
+ align-items: center;
254
+ gap: 0.35rem;
255
+ pointer-events: none;
256
+ opacity: 1;
257
+ transform: translateY(0);
258
+ transition:
259
+ opacity 180ms ease,
260
+ transform 180ms ease;
261
+ }
262
+
263
+ .overlay > * {
264
+ pointer-events: auto;
265
+ }
266
+
267
+ .stage[data-controls-visible="false"] .overlay {
268
+ opacity: 0;
269
+ pointer-events: none;
270
+ }
271
+
272
+ .stage[data-controls-visible="false"] .overlay > * {
273
+ pointer-events: none;
274
+ }
275
+
276
+ .stage[data-controls-visible="false"] .overlay--tools {
277
+ transform: translateY(-4px);
278
+ }
279
+
280
+ .stage[data-controls-visible="false"] .overlay--animation,
281
+ .stage[data-controls-visible="false"] .overlay--stats {
282
+ transform: translateY(4px);
283
+ }
284
+
285
+ .overlay--tools {
286
+ top: 0.55rem;
287
+ right: 0.55rem;
288
+ padding: 0.15rem;
289
+ border: 1px solid var(--border-soft, color-mix(in oklch, currentColor 12%, transparent));
290
+ background: color-mix(in oklch, var(--bg-elevated, var(--bg, #111)) 92%, transparent);
291
+ }
292
+
293
+ .overlay--stats {
294
+ left: 0.55rem;
295
+ bottom: 0.55rem;
296
+ padding: 0.2rem 0.45rem;
297
+ border: 1px solid var(--border-soft, color-mix(in oklch, currentColor 12%, transparent));
298
+ background: color-mix(in oklch, var(--bg-elevated, var(--bg, #111)) 88%, transparent);
299
+ color: var(--text-muted, color-mix(in oklch, currentColor 70%, transparent));
300
+ font-size: 0.68rem;
301
+ font-variant-numeric: tabular-nums;
302
+ gap: 0.55rem;
303
+ }
304
+
305
+ .tools {
306
+ display: inline-flex;
307
+ gap: 0.25rem;
308
+ }
309
+
310
+ .icon-btn {
311
+ appearance: none;
312
+ display: inline-flex;
313
+ align-items: center;
314
+ justify-content: center;
315
+ width: 1.85rem;
316
+ height: 1.85rem;
317
+ padding: 0;
318
+ border: 1px solid transparent;
319
+ background: transparent;
320
+ color: var(--text-muted, color-mix(in oklch, currentColor 70%, transparent));
321
+ cursor: pointer;
322
+ flex: 0 0 auto;
323
+ }
324
+
325
+ .icon-btn:hover {
326
+ color: var(--text, inherit);
327
+ border-color: var(--border, color-mix(in oklch, currentColor 18%, transparent));
328
+ background: color-mix(in oklch, var(--bg-elevated, currentColor) 8%, transparent);
329
+ }
330
+
331
+ .icon-btn:focus-visible {
332
+ color: var(--text, inherit);
333
+ outline: none;
334
+ border-color: var(--accent, #5b9fd4);
335
+ box-shadow: 0 0 0 1px color-mix(in oklch, var(--accent, #5b9fd4) 55%, transparent);
336
+ }
337
+
338
+ .icon-btn svg {
339
+ width: 16px;
340
+ height: 16px;
341
+ display: block;
342
+ flex: none;
343
+ }
344
+
345
+ .overlay--animation {
346
+ left: 0.55rem;
347
+ right: 0.55rem;
348
+ bottom: 0.55rem;
349
+ justify-content: center;
350
+ }
351
+
352
+ .anim-bar {
353
+ display: inline-flex;
354
+ align-items: center;
355
+ gap: 0.25rem;
356
+ max-width: min(44rem, 100%);
357
+ padding: 0.3rem 0.45rem;
358
+ border: 1px solid var(--border-soft, color-mix(in oklch, currentColor 12%, transparent));
359
+ background: color-mix(in oklch, var(--bg-elevated, var(--bg, #111)) 92%, transparent);
360
+ color: var(--text, inherit);
361
+ font-size: 0.72rem;
362
+ font-variant-numeric: tabular-nums;
363
+ border-radius: 0;
364
+ }
365
+
366
+ .anim-select {
367
+ position: relative;
368
+ display: inline-flex;
369
+ align-items: center;
370
+ gap: 0.28rem;
371
+ height: 1.85rem;
372
+ min-width: 0;
373
+ max-width: 12rem;
374
+ padding: 0 1.25rem 0 0.4rem;
375
+ border: 1px solid transparent;
376
+ border-radius: 0;
377
+ background: transparent;
378
+ color: var(--text-muted, color-mix(in oklch, currentColor 78%, transparent));
379
+ flex: 0 1 auto;
380
+ box-sizing: border-box;
381
+ }
382
+
383
+ .anim-select--speed {
384
+ max-width: 4.75rem;
385
+ padding-left: 0.4rem;
386
+ }
387
+
388
+ .anim-select:hover {
389
+ color: var(--text, inherit);
390
+ border-color: var(--border, color-mix(in oklch, currentColor 18%, transparent));
391
+ background: color-mix(in oklch, var(--bg-elevated, currentColor) 8%, transparent);
392
+ }
393
+
394
+ /* Keyboard focus only — mouse/open must not stack a second system ring. */
395
+ .anim-select:has(select:focus-visible) {
396
+ color: var(--text, inherit);
397
+ border-color: var(--accent, #5b9fd4);
398
+ background: color-mix(in oklch, var(--bg-elevated, currentColor) 8%, transparent);
399
+ outline: none;
400
+ box-shadow: 0 0 0 1px color-mix(in oklch, var(--accent, #5b9fd4) 55%, transparent);
401
+ }
402
+
403
+ .anim-select__glyph {
404
+ display: inline-flex;
405
+ flex: 0 0 auto;
406
+ opacity: 0.8;
407
+ pointer-events: none;
408
+ color: inherit;
409
+ }
410
+
411
+ .anim-select__glyph svg {
412
+ width: 14px;
413
+ height: 14px;
414
+ display: block;
415
+ }
416
+
417
+ .anim-select select {
418
+ appearance: none;
419
+ -webkit-appearance: none;
420
+ -moz-appearance: none;
421
+ border: 0;
422
+ background: transparent;
423
+ color: inherit;
424
+ font: inherit;
425
+ font-size: inherit;
426
+ line-height: 1.2;
427
+ padding: 0;
428
+ margin: 0;
429
+ min-width: 0;
430
+ max-width: 100%;
431
+ flex: 1 1 auto;
432
+ cursor: pointer;
433
+ outline: none;
434
+ box-shadow: none;
435
+ }
436
+
437
+ .anim-select select:focus,
438
+ .anim-select select:focus-visible,
439
+ .anim-select select:active {
440
+ outline: none;
441
+ outline-offset: 0;
442
+ box-shadow: none;
443
+ border: 0;
444
+ }
445
+
446
+ .anim-select select::-moz-focus-inner {
447
+ border: 0;
448
+ }
449
+
450
+ .anim-select__caret {
451
+ position: absolute;
452
+ right: 0.3rem;
453
+ top: 50%;
454
+ transform: translateY(-50%);
455
+ display: inline-flex;
456
+ pointer-events: none;
457
+ opacity: 0.75;
458
+ color: inherit;
459
+ }
460
+
461
+ .anim-select__caret svg {
462
+ width: 12px;
463
+ height: 12px;
464
+ display: block;
465
+ }
466
+
467
+ .anim-scrub {
468
+ -webkit-appearance: none;
469
+ appearance: none;
470
+ width: min(14rem, 32vw);
471
+ height: 1.85rem;
472
+ margin: 0 0.25rem;
473
+ background: transparent;
474
+ cursor: pointer;
475
+ flex: 1 1 auto;
476
+ min-width: 6rem;
477
+ border-radius: 0;
478
+ }
479
+
480
+ .anim-scrub:focus {
481
+ outline: none;
482
+ }
483
+
484
+ .anim-scrub:focus-visible {
485
+ box-shadow: 0 0 0 1px var(--accent, #5b9fd4);
486
+ }
487
+
488
+ .anim-scrub::-webkit-slider-runnable-track {
489
+ height: 6px;
490
+ border-radius: 0;
491
+ background: color-mix(in oklch, var(--text, currentColor) 16%, transparent);
492
+ }
493
+
494
+ .anim-scrub::-webkit-slider-thumb {
495
+ -webkit-appearance: none;
496
+ appearance: none;
497
+ width: 12px;
498
+ height: 12px;
499
+ margin-top: -3px;
500
+ border-radius: 0;
501
+ border: 2px solid var(--bg-elevated, var(--bg, #111));
502
+ background: var(--accent, #a195f7);
503
+ box-shadow: 0 0 0 1px color-mix(in oklch, var(--accent, #a195f7) 55%, transparent);
504
+ }
505
+
506
+ .anim-scrub::-moz-range-track {
507
+ height: 6px;
508
+ border-radius: 0;
509
+ background: color-mix(in oklch, var(--text, currentColor) 16%, transparent);
510
+ }
511
+
512
+ .anim-scrub::-moz-range-thumb {
513
+ width: 12px;
514
+ height: 12px;
515
+ border-radius: 0;
516
+ border: 2px solid var(--bg-elevated, var(--bg, #111));
517
+ background: var(--accent, #a195f7);
518
+ box-shadow: 0 0 0 1px color-mix(in oklch, var(--accent, #a195f7) 55%, transparent);
519
+ }
520
+
521
+ .anim-time {
522
+ color: var(--text-muted, color-mix(in oklch, currentColor 70%, transparent));
523
+ min-width: 4.5rem;
524
+ text-align: center;
525
+ }
526
+
527
+ .icon-btn.is-pressed,
528
+ .icon-btn[aria-pressed="true"] {
529
+ color: var(--accent-contrast, #0b0f14);
530
+ background: var(--accent, #a195f7);
531
+ border-color: var(--accent, #a195f7);
532
+ }
533
+
534
+ .icon-btn.is-pressed:hover,
535
+ .icon-btn[aria-pressed="true"]:hover {
536
+ color: var(--accent-contrast, #0b0f14);
537
+ background: color-mix(in oklch, var(--accent, #a195f7) 85%, white);
538
+ }
539
+
540
+ @media (prefers-reduced-motion: reduce) {
541
+ .overlay {
542
+ transition: none;
543
+ }
544
+ }
545
+ `;
546
+ }
547
+ #controller;
548
+ #mountGen;
549
+ #appliedTheme;
550
+ #themeObserver;
551
+ #activeTheme;
552
+ #ready;
553
+ #busy;
554
+ #error;
555
+ #stats;
556
+ #isFullscreen;
557
+ #controlsVisible;
558
+ #controlsIdleTimer;
559
+ #animList;
560
+ #animState;
561
+ #animUnsub;
562
+ #onFullscreenChange;
563
+ connectedCallback() {
564
+ super.connectedCallback();
565
+ this.#syncHostHeight();
566
+ document.addEventListener("fullscreenchange", this.#onFullscreenChange);
567
+ this.#watchAutoTheme();
568
+ if (this.hasUpdated) this.#mountController();
569
+ }
570
+ disconnectedCallback() {
571
+ document.removeEventListener("fullscreenchange", this.#onFullscreenChange);
572
+ this.#themeObserver?.disconnect();
573
+ this.#themeObserver = null;
574
+ this.#clearControlsIdleTimer();
575
+ this.#destroyController();
576
+ super.disconnectedCallback();
577
+ }
578
+ firstUpdated() {
579
+ this.#revealControls();
580
+ this.#mountController();
581
+ }
582
+ updated(changed) {
583
+ if (changed.has("height")) {
584
+ this.#syncHostHeight();
585
+ if (this.#ready) requestAnimationFrame(() => this.#controller?.fit());
586
+ }
587
+ if (changed.has("theme")) {
588
+ this.#watchAutoTheme();
589
+ this.#applyTheme();
590
+ }
591
+ if ((changed.has("source") || changed.has("options")) && this.#ready) this.#applySource();
592
+ if (this.#ready && (changed.has("autoplay") || changed.has("animationLoop") || changed.has("animation") || changed.has("showAnimationControls"))) this.#syncAnimationHostPrefs();
593
+ }
594
+ /** Height lives on the host so flex parents (playground) can fill the pane. */
595
+ #syncHostHeight() {
596
+ this.style.height = cssHeight(this.height);
597
+ }
598
+ /** Resolves when the first interactive render finishes. */
599
+ async ready() {
600
+ await this.updateComplete;
601
+ if (!this.#controller) await this.#mountController();
602
+ if (!this.#controller) throw new Error(this.#error ?? "k-diagram failed to mount");
603
+ await this.#controller.ready();
604
+ }
605
+ fit() {
606
+ this.#controller?.fit();
607
+ }
608
+ zoomIn() {
609
+ this.#controller?.zoomIn();
610
+ }
611
+ zoomOut() {
612
+ this.#controller?.zoomOut();
613
+ }
614
+ resetView() {
615
+ this.#controller?.resetView();
616
+ }
617
+ /** Interactive animation controller (auto path or authored blocks). */
618
+ get animations() {
619
+ return this.#controller?.animations;
620
+ }
621
+ /**
622
+ * Re-apply the current theme name (picks up `registerTheme` token updates
623
+ * even when the theme id is unchanged).
624
+ */
625
+ async refreshTheme() {
626
+ if (!this.#ready || !this.#controller) return;
627
+ const next = resolveTheme(this.theme === "auto" ? "auto" : this.theme);
628
+ this.#activeTheme = next;
629
+ this.#appliedTheme = next;
630
+ const result = await this.#controller.setTheme(next);
631
+ this.#stats = result.stats;
632
+ this.#emitRender(result);
633
+ this.requestUpdate();
634
+ requestAnimationFrame(() => this.#controller?.fit());
635
+ }
636
+ #emitRender(result) {
637
+ this.dispatchEvent(new CustomEvent("kdiagram-render", {
638
+ detail: result,
639
+ bubbles: true,
640
+ composed: true
641
+ }));
642
+ }
643
+ #bindAnimations(controller) {
644
+ this.#animUnsub?.();
645
+ this.#animList = controller.animations.list();
646
+ let lastUiMs = 0;
647
+ let lastSig = "";
648
+ this.#animUnsub = controller.animations.subscribe((state) => {
649
+ this.#animState = state;
650
+ this.#animList = controller.animations.list();
651
+ this.dispatchEvent(new CustomEvent("kdiagram-animation-timeupdate", {
652
+ detail: state,
653
+ bubbles: true,
654
+ composed: true
655
+ }));
656
+ const sig = `${state.id}|${state.playing}|${state.loop}|${state.speed}|${state.durationMs}`;
657
+ const now = performance.now();
658
+ if (sig !== lastSig || !state.playing || now - lastUiMs > 50) {
659
+ lastSig = sig;
660
+ lastUiMs = now;
661
+ this.requestUpdate();
662
+ }
663
+ });
664
+ this.#syncAnimationHostPrefs();
665
+ }
666
+ #syncAnimationHostPrefs() {
667
+ const anim = this.#controller?.animations;
668
+ if (!anim) return;
669
+ this.#animList = anim.list();
670
+ if (this.animationLoop) anim.setLoop(true);
671
+ const preferred = (this.animation ?? "").trim();
672
+ if (preferred) {
673
+ const match = this.#animList.find((a) => a.id === preferred || a.name.toLowerCase() === preferred.toLowerCase());
674
+ if (match && this.#animState.id !== match.id) {
675
+ anim.play(match.id);
676
+ if (!this.autoplay) anim.pause();
677
+ this.dispatchEvent(new CustomEvent("kdiagram-animation-change", {
678
+ detail: { id: match.id },
679
+ bubbles: true,
680
+ composed: true
681
+ }));
682
+ }
683
+ }
684
+ if (this.autoplay && this.#animList.length > 0) anim.play(this.#animState.id ?? this.#animList[0].id);
685
+ }
686
+ #onAnimKeydown;
687
+ #clearControlsIdleTimer() {
688
+ if (this.#controlsIdleTimer == null) return;
689
+ clearTimeout(this.#controlsIdleTimer);
690
+ this.#controlsIdleTimer = null;
691
+ }
692
+ #scheduleControlsHide(delay = CONTROLS_IDLE_MS) {
693
+ this.#clearControlsIdleTimer();
694
+ this.#controlsIdleTimer = setTimeout(() => {
695
+ this.#controlsIdleTimer = null;
696
+ const root = this.renderRoot;
697
+ const focused = root instanceof ShadowRoot ? root.activeElement : null;
698
+ if (focused instanceof Element && focused.closest(".overlay")) return;
699
+ this.#controlsVisible = false;
700
+ this.requestUpdate();
701
+ }, delay);
702
+ }
703
+ #revealControls(persist = false) {
704
+ if (!this.#controlsVisible) {
705
+ this.#controlsVisible = true;
706
+ this.requestUpdate();
707
+ }
708
+ this.#clearControlsIdleTimer();
709
+ if (!persist) this.#scheduleControlsHide();
710
+ }
711
+ #onStagePointerActivity;
712
+ #onStagePointerLeave;
713
+ #onStageFocusIn;
714
+ #onStageFocusOut;
715
+ #onStageKeydown;
716
+ /** Pan/zoom the canvas should release select/scrub focus like clicking the page backdrop. */
717
+ #onStagePointerDown;
718
+ #watchAutoTheme() {
719
+ this.#themeObserver?.disconnect();
720
+ this.#themeObserver = null;
721
+ if (this.theme === "auto") {
722
+ this.#activeTheme = readSiteTheme();
723
+ this.#themeObserver = new MutationObserver(() => {
724
+ const next = readSiteTheme();
725
+ if (next === this.#activeTheme) return;
726
+ this.#activeTheme = next;
727
+ this.#applyTheme();
728
+ this.requestUpdate();
729
+ });
730
+ this.#themeObserver.observe(document.documentElement, {
731
+ attributes: true,
732
+ attributeFilter: ["data-theme"]
733
+ });
734
+ } else this.#activeTheme = resolveTheme(this.theme);
735
+ }
736
+ #viewport() {
737
+ return this.renderRoot.querySelector(".viewport");
738
+ }
739
+ #destroyController() {
740
+ this.#animUnsub?.();
741
+ this.#animUnsub = null;
742
+ this.#animList = [];
743
+ this.#mountGen += 1;
744
+ this.#controller?.destroy();
745
+ this.#controller = null;
746
+ this.#ready = false;
747
+ this.#appliedTheme = null;
748
+ }
749
+ async #mountController() {
750
+ if (!this.#viewport() || !this.isConnected) return;
751
+ const gen = ++this.#mountGen;
752
+ this.#animUnsub?.();
753
+ this.#animUnsub = null;
754
+ this.#controller?.destroy();
755
+ this.#controller = null;
756
+ this.#ready = false;
757
+ this.#appliedTheme = null;
758
+ try {
759
+ const { KDiagram } = await import("@kekonic/diagrams");
760
+ try {
761
+ await KDiagram.ensureFonts();
762
+ } catch {}
763
+ if (gen !== this.#mountGen || !this.isConnected) return;
764
+ const host = this.#viewport();
765
+ if (!host) return;
766
+ host.replaceChildren();
767
+ const mountedSource = this.source;
768
+ const theme = resolveTheme(this.theme === "auto" ? "auto" : this.theme);
769
+ this.#activeTheme = theme;
770
+ const controller = KDiagram.renderToElement(mountedSource, host, {
771
+ ...this.options,
772
+ theme
773
+ });
774
+ if (gen !== this.#mountGen) {
775
+ controller.destroy();
776
+ return;
777
+ }
778
+ this.#controller = controller;
779
+ const result = await controller.ready();
780
+ if (gen !== this.#mountGen) return;
781
+ this.#appliedTheme = theme;
782
+ this.#stats = result.stats;
783
+ this.#ready = true;
784
+ this.#error = result.ok ? null : result.diagnostics[0]?.message ?? "Render failed";
785
+ this.#bindAnimations(controller);
786
+ this.#emitRender(result);
787
+ this.requestUpdate();
788
+ requestAnimationFrame(() => controller.fit());
789
+ } catch (err) {
790
+ if (gen !== this.#mountGen) return;
791
+ this.#error = err instanceof Error ? err.message : String(err);
792
+ this.requestUpdate();
793
+ }
794
+ }
795
+ async #applyTheme() {
796
+ if (!this.#ready || !this.#controller) return;
797
+ const next = resolveTheme(this.theme === "auto" ? "auto" : this.theme);
798
+ this.#activeTheme = next;
799
+ if (this.#appliedTheme === next) {
800
+ this.requestUpdate();
801
+ return;
802
+ }
803
+ this.#appliedTheme = next;
804
+ const result = await this.#controller.setTheme(next);
805
+ this.#stats = result.stats;
806
+ this.#emitRender(result);
807
+ this.requestUpdate();
808
+ requestAnimationFrame(() => this.#controller?.fit());
809
+ }
810
+ async #applySource() {
811
+ const controller = this.#controller;
812
+ if (!this.#ready || !controller) return;
813
+ this.#busy = true;
814
+ this.requestUpdate();
815
+ try {
816
+ const result = await controller.update(this.source, this.options);
817
+ this.#stats = result.stats;
818
+ this.#error = result.ok ? null : result.diagnostics[0]?.message ?? "Update failed";
819
+ this.#animList = controller.animations.list();
820
+ this.#syncAnimationHostPrefs();
821
+ this.#emitRender(result);
822
+ requestAnimationFrame(() => controller.fit());
823
+ } catch (err) {
824
+ this.#error = err instanceof Error ? err.message : String(err);
825
+ } finally {
826
+ this.#busy = false;
827
+ this.requestUpdate();
828
+ }
829
+ }
830
+ async #toggleFullscreen() {
831
+ try {
832
+ if (document.fullscreenElement) await document.exitFullscreen();
833
+ else await this.requestFullscreen();
834
+ } catch {}
835
+ }
836
+ #toggleTheme() {
837
+ this.theme = this.#activeTheme === "dark" ? "light" : "dark";
838
+ }
839
+ #icon(paths, size = 16) {
840
+ return html`
841
+ <svg
842
+ width=${size}
843
+ height=${size}
844
+ viewBox="0 0 24 24"
845
+ fill="none"
846
+ stroke="currentColor"
847
+ stroke-width="1.75"
848
+ stroke-linecap="round"
849
+ stroke-linejoin="round"
850
+ aria-hidden="true"
851
+ >
852
+ ${paths}
853
+ </svg>
854
+ `;
855
+ }
856
+ render() {
857
+ const panHint = this.#busy ? "Updating…" : "Drag to pan / scroll to zoom";
858
+ const showTools = this.showThemeToggle || this.showViewControls;
859
+ const showStatsBadge = this.showStats && this.#stats;
860
+ const showAnim = this.showAnimationControls && this.#animList.length > 0 && this.#controller != null;
861
+ const duration = Math.max(this.#animState.durationMs, 1);
862
+ const timeLabel = `${formatAnimClock(this.#animState.timeMs)} / ${formatAnimClock(this.#animState.durationMs)}`;
863
+ return html`
864
+ <span class="sr-only">Live diagram. ${panHint}.</span>
865
+ ${this.#error ? html`<p class="error" role="status">${this.#error}</p>` : nothing}
866
+ <div
867
+ class="stage"
868
+ data-controls-visible=${String(this.#controlsVisible)}
869
+ tabindex="0"
870
+ @keydown=${this.#onStageKeydown}
871
+ @pointerdown=${this.#onStagePointerDown}
872
+ @pointermove=${this.#onStagePointerActivity}
873
+ @pointerenter=${this.#onStagePointerActivity}
874
+ @pointerleave=${this.#onStagePointerLeave}
875
+ @focusin=${this.#onStageFocusIn}
876
+ @focusout=${this.#onStageFocusOut}
877
+ >
878
+ <div class="viewport" data-theme=${this.#activeTheme}></div>
879
+ ${showStatsBadge && this.#stats ? html`
880
+ <div
881
+ class="overlay overlay--stats"
882
+ aria-label="${this.#stats.layoutAlgorithm} / ${this.#stats.routerAlgorithm}"
883
+ >
884
+ <span>${this.#stats.nodeCount}n</span>
885
+ <span>${this.#stats.edgeCount}e</span>
886
+ <span>${formatMs(this.#stats.totalMs)}</span>
887
+ </div>
888
+ ` : nothing}
889
+ ${showAnim ? html`
890
+ <div
891
+ class="overlay overlay--animation"
892
+ role="toolbar"
893
+ aria-label="Animation controls"
894
+ >
895
+ <div class="anim-bar">
896
+ ${this.#animList.length > 1 ? html`
897
+ <label class="anim-select">
898
+ <span class="anim-select__glyph" aria-hidden="true">
899
+ ${this.#icon([
900
+ svg`<path d="M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3Z"></path>`,
901
+ svg`<path d="m6.2 5.3 3.1 3.9"></path>`,
902
+ svg`<path d="m12.4 3.4 3.1 4"></path>`,
903
+ svg`<path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"></path>`
904
+ ], 14)}
905
+ </span>
906
+ <select
907
+ aria-label="Animation"
908
+ @change=${(e) => {
909
+ const id = e.target.value;
910
+ this.#controller?.animations.play(id);
911
+ this.dispatchEvent(new CustomEvent("kdiagram-animation-change", {
912
+ detail: { id },
913
+ bubbles: true,
914
+ composed: true
915
+ }));
916
+ }}
917
+ >
918
+ ${this.#animList.map((a) => html`
919
+ <option
920
+ value=${a.id}
921
+ ?selected=${a.id === (this.#animState.id ?? this.#animList[0]?.id)}
922
+ >
923
+ ${a.name}
924
+ </option>
925
+ `)}
926
+ </select>
927
+ <span class="anim-select__caret" aria-hidden="true">
928
+ ${this.#icon(svg`<path d="M6 9l6 6 6-6"></path>`, 12)}
929
+ </span>
930
+ </label>
931
+ ` : nothing}
932
+ <button
933
+ type="button"
934
+ class="icon-btn"
935
+ aria-label="Previous step"
936
+ @click=${() => this.#controller?.animations.step(-1)}
937
+ >
938
+ ${this.#icon(svg`<path d="M15 18l-6-6 6-6"></path>`)}
939
+ </button>
940
+ <button
941
+ type="button"
942
+ class="icon-btn"
943
+ aria-label=${this.#animState.playing ? "Pause" : "Play"}
944
+ @click=${() => {
945
+ if (this.#animState.playing) this.#controller?.animations.pause();
946
+ else this.#controller?.animations.play();
947
+ }}
948
+ >
949
+ ${this.#animState.playing ? this.#icon(svg`<path d="M6 4h4v16H6zM14 4h4v16h-4z" fill="currentColor" stroke="none"></path>`) : this.#icon(svg`<path d="M8 5v14l11-7z" fill="currentColor" stroke="none"></path>`)}
950
+ </button>
951
+ <button
952
+ type="button"
953
+ class="icon-btn"
954
+ aria-label="Next step"
955
+ @click=${() => this.#controller?.animations.step(1)}
956
+ >
957
+ ${this.#icon(svg`<path d="M9 18l6-6-6-6"></path>`)}
958
+ </button>
959
+ <input
960
+ class="anim-scrub"
961
+ type="range"
962
+ min="0"
963
+ max=${duration}
964
+ step="16"
965
+ .value=${String(this.#animState.timeMs)}
966
+ aria-label="Scrub animation"
967
+ @input=${(e) => {
968
+ const ms = Number(e.target.value);
969
+ this.#controller?.animations.seek(ms);
970
+ }}
971
+ />
972
+ <span class="anim-time">${timeLabel}</span>
973
+ <label class="anim-select anim-select--speed">
974
+ <select
975
+ aria-label="Playback speed"
976
+ @change=${(e) => {
977
+ const rate = Number(e.target.value);
978
+ this.#controller?.animations.setSpeed(rate);
979
+ }}
980
+ >
981
+ ${SPEED_OPTIONS.map((rate) => {
982
+ const value = formatSpeedValue(rate);
983
+ return html`
984
+ <option
985
+ value=${value}
986
+ ?selected=${value === formatSpeedValue(this.#animState.speed)}
987
+ >
988
+ ${formatSpeedLabel(rate)}
989
+ </option>
990
+ `;
991
+ })}
992
+ </select>
993
+ <span class="anim-select__caret" aria-hidden="true">
994
+ ${this.#icon(svg`<path d="M6 9l6 6 6-6"></path>`, 12)}
995
+ </span>
996
+ </label>
997
+ <button
998
+ type="button"
999
+ class="icon-btn ${this.#animState.loop ? "is-pressed" : ""}"
1000
+ aria-label=${this.#animState.loop ? "Loop on" : "Loop off"}
1001
+ aria-pressed=${this.#animState.loop ? "true" : "false"}
1002
+ title=${this.#animState.loop ? "Loop on" : "Loop off"}
1003
+ @click=${() => this.#controller?.animations.setLoop(!this.#animState.loop)}
1004
+ >
1005
+ ${this.#icon(svg`<path d="M17 1l4 4-4 4M3 11V9a4 4 0 0 1 4-4h14M7 23l-4-4 4-4M21 13v2a4 4 0 0 1-4 4H3"></path>`)}
1006
+ </button>
1007
+ </div>
1008
+ </div>
1009
+ ` : nothing}
1010
+ ${showTools ? html`
1011
+ <div class="overlay overlay--tools" role="toolbar" aria-label="Diagram controls">
1012
+ ${this.showViewControls ? html`
1013
+ <div class="tools" role="group" aria-label="View">
1014
+ <button
1015
+ type="button"
1016
+ class="icon-btn"
1017
+ aria-label="Zoom out"
1018
+ @click=${() => this.zoomOut()}
1019
+ >
1020
+ ${this.#icon(svg`<path d="M5 12h14"></path>`)}
1021
+ </button>
1022
+ <button
1023
+ type="button"
1024
+ class="icon-btn"
1025
+ aria-label="Zoom in"
1026
+ @click=${() => this.zoomIn()}
1027
+ >
1028
+ ${this.#icon(svg`<path d="M12 5v14M5 12h14"></path>`)}
1029
+ </button>
1030
+ <button
1031
+ type="button"
1032
+ class="icon-btn"
1033
+ aria-label="Fit to view"
1034
+ @click=${() => this.fit()}
1035
+ >
1036
+ ${this.#icon(svg`<path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"></path>`)}
1037
+ </button>
1038
+ <button
1039
+ type="button"
1040
+ class="icon-btn"
1041
+ aria-label=${this.#isFullscreen ? "Exit fullscreen" : "Fullscreen"}
1042
+ @click=${() => void this.#toggleFullscreen()}
1043
+ >
1044
+ ${this.#isFullscreen ? this.#icon(svg`<path d="M4 14h6v6M20 10h-6V4M14 10l7-7M3 21l7-7"></path>`) : this.#icon(svg`<path d="M15 3h6v6M9 21H3v-6M21 3l-7 7M3 21l7-7"></path>`)}
1045
+ </button>
1046
+ </div>
1047
+ ` : nothing}
1048
+ ${this.showThemeToggle ? html`
1049
+ <button
1050
+ type="button"
1051
+ class="icon-btn"
1052
+ aria-label=${this.#activeTheme === "dark" ? "Switch to light theme" : "Switch to dark theme"}
1053
+ @click=${() => this.#toggleTheme()}
1054
+ >
1055
+ ${this.#activeTheme === "dark" ? this.#icon([svg`<circle cx="12" cy="12" r="4"></circle>`, svg`<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"></path>`]) : this.#icon(svg`<path d="M21 14.5A8.5 8.5 0 1 1 9.5 3a7 7 0 0 0 11.5 11.5z"></path>`)}
1056
+ </button>
1057
+ ` : nothing}
1058
+ </div>
1059
+ ` : nothing}
1060
+ </div>
1061
+ `;
1062
+ }
1063
+ };
1064
+ function formatAnimClock(ms) {
1065
+ const total = Math.max(0, Math.round(ms / 1e3));
1066
+ const m = Math.floor(total / 60);
1067
+ const s = total % 60;
1068
+ return `${m}:${String(s).padStart(2, "0")}`;
1069
+ }
1070
+ const SPEED_OPTIONS = [
1071
+ .5,
1072
+ .75,
1073
+ 1,
1074
+ 1.25,
1075
+ 1.5,
1076
+ 2
1077
+ ];
1078
+ function formatSpeedValue(rate) {
1079
+ const snapped = SPEED_OPTIONS.find((o) => Math.abs(o - rate) < .01) ?? SPEED_OPTIONS.reduce((best, o) => Math.abs(o - rate) < Math.abs(best - rate) ? o : best);
1080
+ return String(snapped);
1081
+ }
1082
+ function formatSpeedLabel(rate) {
1083
+ return `${rate}×`;
1084
+ }
1085
+ //#endregion
1086
+ //#region src/index.ts
1087
+ /** Tag name for `<k-diagram>`. */
1088
+ const K_DIAGRAM_TAG = "k-diagram";
1089
+ /** Register `<k-diagram>` (idempotent). Called on package import. */
1090
+ function registerKDiagramElements() {
1091
+ if (!customElements.get("k-diagram")) customElements.define(K_DIAGRAM_TAG, KDiagramElement);
1092
+ }
1093
+ registerKDiagramElements();
1094
+ //#endregion
1095
+ export { KDiagramElement, K_DIAGRAM_TAG, registerKDiagramElements };
1096
+
1097
+ //# sourceMappingURL=index.mjs.map