@alkemdotdev/alkemist-components 1.0.0-beta.1
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/CHANGELOG.md +5 -0
- package/LICENSE +201 -0
- package/README.md +18 -0
- package/package.json +55 -0
- package/src/AlkChart.astro +89 -0
- package/src/AlkCode.astro +35 -0
- package/src/AlkLayout.astro +226 -0
- package/src/AlkMath.astro +38 -0
- package/src/AlkModel.astro +100 -0
- package/src/AlkShader.astro +130 -0
- package/src/HeaderIcon.astro +54 -0
- package/src/chart.css +84 -0
- package/src/chart.ts +232 -0
- package/src/charts.ts +401 -0
- package/src/code-copy.ts +39 -0
- package/src/code-theme.ts +199 -0
- package/src/figure.css +127 -0
- package/src/header.css +252 -0
- package/src/header.ts +90 -0
- package/src/index.ts +14 -0
- package/src/model.css +108 -0
- package/src/model.ts +509 -0
- package/src/shader.css +86 -0
- package/src/shader.ts +375 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
import './model.css';
|
|
3
|
+
import './figure.css';
|
|
4
|
+
|
|
5
|
+
export interface AlkModelProps {
|
|
6
|
+
/** A glTF 2.0 .glb or .gltf URL. Compression decoders are not included. */
|
|
7
|
+
src: string;
|
|
8
|
+
title: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
/** Static image shown before loading and when WebGL is unavailable. */
|
|
11
|
+
poster?: string;
|
|
12
|
+
class?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type Props = AlkModelProps;
|
|
16
|
+
const { src, title, description, poster, class: className } = Astro.props;
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
<alk-model
|
|
20
|
+
class:list={['alk-model', className]}
|
|
21
|
+
data-src={src}
|
|
22
|
+
data-state="idle"
|
|
23
|
+
>
|
|
24
|
+
<figure class="alk-figure" aria-label={title}>
|
|
25
|
+
<figcaption class="alk-model-heading alk-figure-heading">
|
|
26
|
+
<div>
|
|
27
|
+
<p class="alk-model-label alk-figure-label">3D model · glTF 2.0</p>
|
|
28
|
+
<h3 class="alk-figure-title">{title}</h3>
|
|
29
|
+
{
|
|
30
|
+
description && (
|
|
31
|
+
<p class="alk-model-description alk-figure-description">
|
|
32
|
+
{description}
|
|
33
|
+
</p>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
</div>
|
|
37
|
+
</figcaption>
|
|
38
|
+
<div class="alk-model-viewport" aria-busy="false">
|
|
39
|
+
{
|
|
40
|
+
poster && (
|
|
41
|
+
<img
|
|
42
|
+
class="alk-model-poster"
|
|
43
|
+
src={poster}
|
|
44
|
+
alt={`Static view of ${title}`}
|
|
45
|
+
loading="lazy"
|
|
46
|
+
width="960"
|
|
47
|
+
height="640"
|
|
48
|
+
/>
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
<canvas
|
|
52
|
+
aria-label={`${title}. Drag to orbit; use arrow keys to rotate and plus or minus to zoom.`}
|
|
53
|
+
role="img"
|
|
54
|
+
tabindex="0"></canvas>
|
|
55
|
+
<div class="alk-model-axis" aria-hidden="true">
|
|
56
|
+
<span>X</span><span>Y</span><span>Z</span>
|
|
57
|
+
</div>
|
|
58
|
+
<p class="alk-model-hint">Drag to orbit · scroll to zoom</p>
|
|
59
|
+
</div>
|
|
60
|
+
<div
|
|
61
|
+
class="alk-model-toolbar alk-figure-controls"
|
|
62
|
+
aria-label={`${title} controls`}
|
|
63
|
+
>
|
|
64
|
+
<div class="alk-model-presets" role="group" aria-label="Camera view">
|
|
65
|
+
<button type="button" data-view="perspective" disabled
|
|
66
|
+
>Reset view</button
|
|
67
|
+
>
|
|
68
|
+
<button type="button" data-view="front" disabled>Front</button>
|
|
69
|
+
<button type="button" data-view="top" disabled>Top</button>
|
|
70
|
+
</div>
|
|
71
|
+
<div class="alk-model-switches">
|
|
72
|
+
<label
|
|
73
|
+
><input type="checkbox" data-wireframe disabled /> Wireframe</label
|
|
74
|
+
>
|
|
75
|
+
<label><input type="checkbox" data-spin disabled /> Spin</label>
|
|
76
|
+
</div>
|
|
77
|
+
</div>
|
|
78
|
+
<div class="alk-model-footer alk-figure-footer">
|
|
79
|
+
<p
|
|
80
|
+
class="alk-model-status alk-figure-status"
|
|
81
|
+
role="status"
|
|
82
|
+
aria-live="polite"
|
|
83
|
+
>
|
|
84
|
+
Interactive view loads when visible.
|
|
85
|
+
</p>
|
|
86
|
+
<a href={src} download>Download model <span aria-hidden="true">↓</span></a
|
|
87
|
+
>
|
|
88
|
+
</div>
|
|
89
|
+
<noscript
|
|
90
|
+
><p class="alk-figure-caption">
|
|
91
|
+
Enable JavaScript to explore this model. The original file is available
|
|
92
|
+
above.
|
|
93
|
+
</p></noscript
|
|
94
|
+
>
|
|
95
|
+
</figure>
|
|
96
|
+
</alk-model>
|
|
97
|
+
|
|
98
|
+
<script>
|
|
99
|
+
import './model';
|
|
100
|
+
</script>
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
---
|
|
2
|
+
import './shader.css';
|
|
3
|
+
import './figure.css';
|
|
4
|
+
import { ALK_INKS } from '@alkemdotdev/alkemist-theme/palette';
|
|
5
|
+
|
|
6
|
+
export interface AlkShaderProps {
|
|
7
|
+
title?: string;
|
|
8
|
+
/** Spatial frequency, clamped to the visible control's range of 3–18. */
|
|
9
|
+
frequency?: number;
|
|
10
|
+
/** Rotation of the two sources in degrees, clamped to 0–180. */
|
|
11
|
+
angle?: number;
|
|
12
|
+
class?: string;
|
|
13
|
+
}
|
|
14
|
+
type Props = AlkShaderProps;
|
|
15
|
+
const {
|
|
16
|
+
title = 'Interference field',
|
|
17
|
+
frequency = 9,
|
|
18
|
+
angle = 24,
|
|
19
|
+
class: className,
|
|
20
|
+
} = Astro.props;
|
|
21
|
+
const initialFrequency = Math.max(
|
|
22
|
+
3,
|
|
23
|
+
Math.min(18, Number.isFinite(frequency) ? frequency : 9),
|
|
24
|
+
);
|
|
25
|
+
const initialAngle = Math.max(
|
|
26
|
+
0,
|
|
27
|
+
Math.min(180, Number.isFinite(angle) ? angle : 24),
|
|
28
|
+
);
|
|
29
|
+
const fallbackInks = ALK_INKS;
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
<alk-shader class:list={['alk-shader', className]} data-state="idle">
|
|
33
|
+
<figure class="alk-figure" aria-label={title}>
|
|
34
|
+
<figcaption class="alk-shader-heading alk-figure-heading">
|
|
35
|
+
<div>
|
|
36
|
+
<p class="alk-figure-label">Shader · two wave sources</p>
|
|
37
|
+
<h3 class="alk-figure-title">{title}</h3>
|
|
38
|
+
</div>
|
|
39
|
+
<span class="alk-shader-tag alk-figure-format">GLSL</span>
|
|
40
|
+
</figcaption>
|
|
41
|
+
<div class="alk-shader-viewport">
|
|
42
|
+
<svg
|
|
43
|
+
class="alk-shader-poster"
|
|
44
|
+
viewBox="0 0 960 520"
|
|
45
|
+
role="img"
|
|
46
|
+
aria-label="Static illustration of two overlapping concentric wave sources; the interactive shader loads when visible."
|
|
47
|
+
>
|
|
48
|
+
{
|
|
49
|
+
[-1, 1].map((side) => (
|
|
50
|
+
<g
|
|
51
|
+
transform={`translate(${480 + side * 145} 260)`}
|
|
52
|
+
fill="none"
|
|
53
|
+
stroke-width="2"
|
|
54
|
+
>
|
|
55
|
+
{Array.from({ length: 14 }, (_, index) => {
|
|
56
|
+
const { id, hex } = fallbackInks[index % fallbackInks.length];
|
|
57
|
+
return (
|
|
58
|
+
<circle
|
|
59
|
+
r={28 + index * 22}
|
|
60
|
+
style={`stroke:var(--alk-ink-${id}, ${hex})`}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
})}
|
|
64
|
+
</g>
|
|
65
|
+
))
|
|
66
|
+
}
|
|
67
|
+
</svg>
|
|
68
|
+
<canvas
|
|
69
|
+
role="img"
|
|
70
|
+
aria-label={`${title}: interference contours from two point sources, rendered with the eight Alkemist inks.`}
|
|
71
|
+
></canvas>
|
|
72
|
+
<p class="alk-shader-coordinate" aria-hidden="true">
|
|
73
|
+
ψ = sin(k r₁ − φ) + sin(k r₂ − φ)
|
|
74
|
+
</p>
|
|
75
|
+
</div>
|
|
76
|
+
<div
|
|
77
|
+
class="alk-shader-controls alk-figure-controls"
|
|
78
|
+
aria-label={`${title} controls`}
|
|
79
|
+
>
|
|
80
|
+
<label
|
|
81
|
+
><span
|
|
82
|
+
>Frequency <output data-frequency-output
|
|
83
|
+
>{initialFrequency.toFixed(1)}</output
|
|
84
|
+
></span
|
|
85
|
+
><input
|
|
86
|
+
type="range"
|
|
87
|
+
min="3"
|
|
88
|
+
max="18"
|
|
89
|
+
step="0.1"
|
|
90
|
+
value={initialFrequency}
|
|
91
|
+
data-frequency
|
|
92
|
+
disabled
|
|
93
|
+
/></label
|
|
94
|
+
>
|
|
95
|
+
<label
|
|
96
|
+
><span
|
|
97
|
+
>Source angle <output data-angle-output
|
|
98
|
+
>{initialAngle.toFixed(0)}°</output
|
|
99
|
+
></span
|
|
100
|
+
><input
|
|
101
|
+
type="range"
|
|
102
|
+
min="0"
|
|
103
|
+
max="180"
|
|
104
|
+
step="1"
|
|
105
|
+
value={initialAngle}
|
|
106
|
+
data-angle
|
|
107
|
+
disabled
|
|
108
|
+
/></label
|
|
109
|
+
>
|
|
110
|
+
<button type="button" data-play aria-pressed="false" disabled
|
|
111
|
+
>Play waves</button
|
|
112
|
+
>
|
|
113
|
+
</div>
|
|
114
|
+
<div class="alk-shader-footer alk-figure-footer">
|
|
115
|
+
<p class="alk-figure-status" role="status" aria-live="polite">
|
|
116
|
+
Shader loads when visible.
|
|
117
|
+
</p>
|
|
118
|
+
</div>
|
|
119
|
+
<noscript
|
|
120
|
+
><p class="alk-shader-nojs alk-figure-caption">
|
|
121
|
+
Enable JavaScript to change the frequency and angle or animate the
|
|
122
|
+
field.
|
|
123
|
+
</p></noscript
|
|
124
|
+
>
|
|
125
|
+
</figure>
|
|
126
|
+
</alk-shader>
|
|
127
|
+
|
|
128
|
+
<script>
|
|
129
|
+
import './shader';
|
|
130
|
+
</script>
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
---
|
|
2
|
+
interface Props {
|
|
3
|
+
name: 'light' | 'dark' | 'system' | 'menu' | 'check' | 'chevron';
|
|
4
|
+
}
|
|
5
|
+
const { name } = Astro.props;
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
<svg
|
|
9
|
+
viewBox="0 0 24 24"
|
|
10
|
+
fill="none"
|
|
11
|
+
stroke="currentColor"
|
|
12
|
+
stroke-width="1.5"
|
|
13
|
+
stroke-linecap="round"
|
|
14
|
+
stroke-linejoin="round"
|
|
15
|
+
aria-hidden="true"
|
|
16
|
+
>
|
|
17
|
+
{
|
|
18
|
+
name === 'light' && (
|
|
19
|
+
<>
|
|
20
|
+
<>
|
|
21
|
+
<circle cx="12" cy="12" r="4" />
|
|
22
|
+
<path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1.4 1.4m11.2 11.2L19 19M5 19l1.4-1.4M17.6 6.4 19 5" />
|
|
23
|
+
</>
|
|
24
|
+
</>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
{
|
|
28
|
+
name === 'dark' && (
|
|
29
|
+
<path d="M20.7 13A8.8 8.8 0 0 1 11 3.3 8.8 8.8 0 1 0 20.7 13Z" />
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
{
|
|
33
|
+
name === 'system' && (
|
|
34
|
+
<>
|
|
35
|
+
<>
|
|
36
|
+
<rect x="3" y="4" width="18" height="13" rx="2" />
|
|
37
|
+
<path d="M8 21h8m-4-4v4" />
|
|
38
|
+
</>
|
|
39
|
+
</>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
{
|
|
43
|
+
name === 'menu' && (
|
|
44
|
+
<>
|
|
45
|
+
<>
|
|
46
|
+
<path class="alk-menu-line alk-menu-line-top" d="M4 8h16" />
|
|
47
|
+
<path class="alk-menu-line alk-menu-line-bottom" d="M4 16h16" />
|
|
48
|
+
</>
|
|
49
|
+
</>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
{name === 'check' && <path d="m5 12 4 4L19 6" />}
|
|
53
|
+
{name === 'chevron' && <path d="m9 5 7 7-7 7" />}
|
|
54
|
+
</svg>
|
package/src/chart.css
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
.alk-chart {
|
|
2
|
+
display: block;
|
|
3
|
+
min-width: 0;
|
|
4
|
+
width: 100%;
|
|
5
|
+
margin: 1.5rem 0;
|
|
6
|
+
}
|
|
7
|
+
.alk-chart-canvas {
|
|
8
|
+
width: 100%;
|
|
9
|
+
min-width: 0;
|
|
10
|
+
position: relative;
|
|
11
|
+
padding: 0 0.5rem 0.75rem;
|
|
12
|
+
}
|
|
13
|
+
.alk-chart-canvas .vega-embed {
|
|
14
|
+
display: block;
|
|
15
|
+
padding: 0;
|
|
16
|
+
width: 100%;
|
|
17
|
+
}
|
|
18
|
+
.alk-chart-canvas svg {
|
|
19
|
+
display: block;
|
|
20
|
+
max-width: 100%;
|
|
21
|
+
height: auto;
|
|
22
|
+
}
|
|
23
|
+
.alk-chart[data-state='error'] .alk-chart-canvas {
|
|
24
|
+
min-height: 0 !important;
|
|
25
|
+
}
|
|
26
|
+
.alk-chart[data-state='error'] .alk-chart-hint {
|
|
27
|
+
display: none;
|
|
28
|
+
}
|
|
29
|
+
.alk-chart-tools:not(:has(button:not([hidden]))) {
|
|
30
|
+
display: none;
|
|
31
|
+
}
|
|
32
|
+
.alk-chart-hint {
|
|
33
|
+
color: var(--alk-muted, #5e5e5e);
|
|
34
|
+
font: 0.7rem/1.5 var(--alk-mono, ui-monospace, monospace);
|
|
35
|
+
}
|
|
36
|
+
.alk-chart-table {
|
|
37
|
+
border-top: 1px solid var(--alk-rule, #ccc);
|
|
38
|
+
margin: 0 var(--alk-figure-gutter);
|
|
39
|
+
font-size: 0.76rem;
|
|
40
|
+
}
|
|
41
|
+
.alk-chart-table summary {
|
|
42
|
+
cursor: pointer;
|
|
43
|
+
padding: 0.7rem 0;
|
|
44
|
+
}
|
|
45
|
+
.alk-chart-table-scroll {
|
|
46
|
+
max-height: 23rem;
|
|
47
|
+
overflow: auto;
|
|
48
|
+
margin-bottom: 0.85rem;
|
|
49
|
+
}
|
|
50
|
+
.alk-chart-table-scroll table {
|
|
51
|
+
font: 0.75rem var(--alk-mono, ui-monospace, monospace);
|
|
52
|
+
width: 100%;
|
|
53
|
+
}
|
|
54
|
+
.alk-chart-table-scroll caption {
|
|
55
|
+
text-align: left;
|
|
56
|
+
padding-bottom: 0.8rem;
|
|
57
|
+
color: var(--alk-muted, #5e5e5e);
|
|
58
|
+
}
|
|
59
|
+
.alk-chart-table-scroll th {
|
|
60
|
+
position: sticky;
|
|
61
|
+
top: 0;
|
|
62
|
+
background: var(--alk-paper, #fff);
|
|
63
|
+
}
|
|
64
|
+
.alk-chart-table-scroll td,
|
|
65
|
+
.alk-chart-table-scroll th {
|
|
66
|
+
white-space: nowrap;
|
|
67
|
+
padding: 0.55rem 0.7rem;
|
|
68
|
+
}
|
|
69
|
+
#vg-tooltip-element {
|
|
70
|
+
font-family: var(--alk-sans, system-ui, sans-serif);
|
|
71
|
+
background: var(--alk-paper, #fff);
|
|
72
|
+
color: var(--alk-ink, #111);
|
|
73
|
+
border: 1px solid var(--alk-rule, #ccc);
|
|
74
|
+
border-radius: 4px;
|
|
75
|
+
box-shadow: 0 3px 18px #0002;
|
|
76
|
+
}
|
|
77
|
+
#vg-tooltip-element td.key {
|
|
78
|
+
color: var(--alk-muted, #5e5e5e);
|
|
79
|
+
}
|
|
80
|
+
@media (max-width: 600px) {
|
|
81
|
+
.alk-chart-hint {
|
|
82
|
+
width: 100%;
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/chart.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { ALK_INKS } from '@alkemdotdev/alkemist-theme/palette';
|
|
2
|
+
import type embed from 'vega-embed';
|
|
3
|
+
import type { Result } from 'vega-embed';
|
|
4
|
+
import {
|
|
5
|
+
alkChartInks,
|
|
6
|
+
createAlkChartSpec,
|
|
7
|
+
prepareAlkChartRows,
|
|
8
|
+
type AlkChartProps,
|
|
9
|
+
type AlkChartRow,
|
|
10
|
+
type AlkChartTheme,
|
|
11
|
+
} from './charts';
|
|
12
|
+
|
|
13
|
+
class AlkChartElement extends HTMLElement {
|
|
14
|
+
private config!: AlkChartProps;
|
|
15
|
+
private rows?: AlkChartRow[];
|
|
16
|
+
private raw?: AlkChartRow[];
|
|
17
|
+
private embed?: typeof embed;
|
|
18
|
+
private result?: Result;
|
|
19
|
+
private visible?: IntersectionObserver;
|
|
20
|
+
private resize?: ResizeObserver;
|
|
21
|
+
private abort?: AbortController;
|
|
22
|
+
private generation = 0;
|
|
23
|
+
private width = 0;
|
|
24
|
+
private resizeTimer?: number;
|
|
25
|
+
private themeChange = () => {
|
|
26
|
+
if (this.rows) void this.render();
|
|
27
|
+
};
|
|
28
|
+
private resetView = () => {
|
|
29
|
+
void this.render();
|
|
30
|
+
};
|
|
31
|
+
private retryLoad = () => {
|
|
32
|
+
void this.load();
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
connectedCallback() {
|
|
36
|
+
this.config = JSON.parse(this.dataset.config ?? '{}');
|
|
37
|
+
this.abort = new AbortController();
|
|
38
|
+
this.querySelector('[data-chart-reset]')?.addEventListener(
|
|
39
|
+
'click',
|
|
40
|
+
this.resetView,
|
|
41
|
+
);
|
|
42
|
+
this.querySelector('[data-chart-retry]')?.addEventListener(
|
|
43
|
+
'click',
|
|
44
|
+
this.retryLoad,
|
|
45
|
+
);
|
|
46
|
+
window.addEventListener('alk:theme-change', this.themeChange);
|
|
47
|
+
this.resize = new ResizeObserver(() => {
|
|
48
|
+
const width = Math.floor(this.canvas.clientWidth);
|
|
49
|
+
if (width === this.width || width <= 0) return;
|
|
50
|
+
this.width = width;
|
|
51
|
+
window.clearTimeout(this.resizeTimer);
|
|
52
|
+
// Tick density and legend columns are compiled from the container width.
|
|
53
|
+
// A new specification keeps those readable after a desktop/mobile resize.
|
|
54
|
+
this.resizeTimer = window.setTimeout(() => {
|
|
55
|
+
void this.render();
|
|
56
|
+
}, 120);
|
|
57
|
+
});
|
|
58
|
+
this.resize.observe(this.canvas);
|
|
59
|
+
this.visible = new IntersectionObserver(
|
|
60
|
+
(entries) => {
|
|
61
|
+
if (entries.some((entry) => entry.isIntersecting)) {
|
|
62
|
+
this.visible?.disconnect();
|
|
63
|
+
void this.load();
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
{ rootMargin: '160px' },
|
|
67
|
+
);
|
|
68
|
+
this.visible.observe(this);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
disconnectedCallback() {
|
|
72
|
+
this.generation++;
|
|
73
|
+
this.abort?.abort();
|
|
74
|
+
this.visible?.disconnect();
|
|
75
|
+
this.resize?.disconnect();
|
|
76
|
+
window.clearTimeout(this.resizeTimer);
|
|
77
|
+
this.result?.finalize();
|
|
78
|
+
this.result = undefined;
|
|
79
|
+
window.removeEventListener('alk:theme-change', this.themeChange);
|
|
80
|
+
this.querySelector('[data-chart-reset]')?.removeEventListener(
|
|
81
|
+
'click',
|
|
82
|
+
this.resetView,
|
|
83
|
+
);
|
|
84
|
+
this.querySelector('[data-chart-retry]')?.removeEventListener(
|
|
85
|
+
'click',
|
|
86
|
+
this.retryLoad,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private get canvas() {
|
|
91
|
+
return this.querySelector<HTMLElement>('.alk-chart-canvas')!;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private setStatus(message: string, state: 'loading' | 'ready' | 'error') {
|
|
95
|
+
this.dataset.state = state;
|
|
96
|
+
this.querySelector('.alk-chart-status')!.textContent = message;
|
|
97
|
+
this.canvas.setAttribute('aria-busy', String(state === 'loading'));
|
|
98
|
+
const reset = this.querySelector<HTMLButtonElement>('[data-chart-reset]');
|
|
99
|
+
if (reset) reset.disabled = state !== 'ready';
|
|
100
|
+
this.querySelector<HTMLButtonElement>('[data-chart-retry]')!.hidden =
|
|
101
|
+
state !== 'error';
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async load() {
|
|
105
|
+
const generation = ++this.generation;
|
|
106
|
+
this.setStatus('Loading the CSV and chart engine…', 'loading');
|
|
107
|
+
try {
|
|
108
|
+
const [embedModule, vega, response] = await Promise.all([
|
|
109
|
+
import('vega-embed'),
|
|
110
|
+
import('vega'),
|
|
111
|
+
fetch(this.config.src, { signal: this.abort?.signal }),
|
|
112
|
+
]);
|
|
113
|
+
if (!response.ok)
|
|
114
|
+
throw new Error(`CSV request failed (${response.status}).`);
|
|
115
|
+
const csv = await response.text();
|
|
116
|
+
if (!this.isConnected || generation !== this.generation) return;
|
|
117
|
+
const raw = vega.read(csv, { type: 'csv' }) as AlkChartRow[];
|
|
118
|
+
this.rows = prepareAlkChartRows(raw, this.config);
|
|
119
|
+
this.raw = raw;
|
|
120
|
+
this.embed = embedModule.default;
|
|
121
|
+
this.renderTable();
|
|
122
|
+
await this.render();
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (!this.isConnected || generation !== this.generation) return;
|
|
125
|
+
this.fail(error);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private resolveTheme(): AlkChartTheme {
|
|
130
|
+
const probe = document.createElement('span');
|
|
131
|
+
probe.style.display = 'none';
|
|
132
|
+
const figure = this.querySelector<HTMLElement>('.alk-figure')!;
|
|
133
|
+
figure.append(probe);
|
|
134
|
+
const resolve = (property: string, fallback: string) => {
|
|
135
|
+
probe.style.color = `var(${property}, ${fallback})`;
|
|
136
|
+
return getComputedStyle(probe).color;
|
|
137
|
+
};
|
|
138
|
+
const theme: AlkChartTheme = {
|
|
139
|
+
text: getComputedStyle(figure).color,
|
|
140
|
+
rule: resolve('--alk-rule', '#ccc'),
|
|
141
|
+
inks: Object.fromEntries(
|
|
142
|
+
alkChartInks.map((ink) => [
|
|
143
|
+
ink,
|
|
144
|
+
resolve(
|
|
145
|
+
`--alk-ink-${ink}`,
|
|
146
|
+
ALK_INKS.find((color) => color.id === ink)!.hex,
|
|
147
|
+
),
|
|
148
|
+
]),
|
|
149
|
+
) as AlkChartTheme['inks'],
|
|
150
|
+
};
|
|
151
|
+
probe.remove();
|
|
152
|
+
return theme;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private async render() {
|
|
156
|
+
if (!this.rows || !this.embed || !this.isConnected) return;
|
|
157
|
+
window.clearTimeout(this.resizeTimer);
|
|
158
|
+
const generation = ++this.generation;
|
|
159
|
+
this.setStatus('Rendering the chart…', 'loading');
|
|
160
|
+
try {
|
|
161
|
+
this.width = Math.max(1, Math.floor(this.canvas.clientWidth));
|
|
162
|
+
const mount = document.createElement('div');
|
|
163
|
+
const result = await this.embed(
|
|
164
|
+
mount,
|
|
165
|
+
createAlkChartSpec(
|
|
166
|
+
this.config,
|
|
167
|
+
this.rows,
|
|
168
|
+
this.resolveTheme(),
|
|
169
|
+
this.width,
|
|
170
|
+
),
|
|
171
|
+
{
|
|
172
|
+
actions: false,
|
|
173
|
+
renderer: 'svg',
|
|
174
|
+
defaultStyle: false,
|
|
175
|
+
tooltip: { theme: 'custom' },
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
if (!this.isConnected || generation !== this.generation) {
|
|
179
|
+
result.finalize();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
this.result?.finalize();
|
|
183
|
+
this.result = result;
|
|
184
|
+
this.canvas.replaceChildren(mount);
|
|
185
|
+
this.setStatus(
|
|
186
|
+
`${this.rows.length.toLocaleString()} rows loaded from CSV.`,
|
|
187
|
+
'ready',
|
|
188
|
+
);
|
|
189
|
+
} catch (error) {
|
|
190
|
+
if (this.isConnected && generation === this.generation) this.fail(error);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
private fail(error: unknown) {
|
|
195
|
+
this.result?.finalize();
|
|
196
|
+
this.result = undefined;
|
|
197
|
+
this.canvas.replaceChildren();
|
|
198
|
+
this.setStatus(
|
|
199
|
+
`Chart unavailable: ${error instanceof Error ? error.message : 'An unexpected error occurred.'} The source CSV is still available below.`,
|
|
200
|
+
'error',
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private renderTable() {
|
|
205
|
+
if (!this.raw) return;
|
|
206
|
+
const fields = Object.keys(this.raw[0]);
|
|
207
|
+
const visibleRows = this.raw.slice(0, 100);
|
|
208
|
+
const table = document.createElement('table');
|
|
209
|
+
const caption = table.createCaption();
|
|
210
|
+
caption.textContent = `${this.config.title}. ${this.raw.length > 100 ? `Showing the first 100 of ${this.raw.length} rows; download the CSV for all rows.` : `All ${this.raw.length} source rows.`} Empty cells are missing values.`;
|
|
211
|
+
const heading = table.createTHead().insertRow();
|
|
212
|
+
for (const field of fields) {
|
|
213
|
+
const cell = document.createElement('th');
|
|
214
|
+
cell.scope = 'col';
|
|
215
|
+
cell.textContent = field;
|
|
216
|
+
heading.append(cell);
|
|
217
|
+
}
|
|
218
|
+
const body = table.createTBody();
|
|
219
|
+
for (const row of visibleRows) {
|
|
220
|
+
const tr = body.insertRow();
|
|
221
|
+
for (const field of fields)
|
|
222
|
+
tr.insertCell().textContent =
|
|
223
|
+
row[field] === null ? '' : String(row[field]);
|
|
224
|
+
}
|
|
225
|
+
this.querySelector('.alk-chart-table-scroll')!.replaceChildren(table);
|
|
226
|
+
this.querySelector('[data-chart-count]')!.textContent =
|
|
227
|
+
`(${this.raw.length.toLocaleString()} rows)`;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!customElements.get('alk-chart'))
|
|
232
|
+
customElements.define('alk-chart', AlkChartElement);
|