@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.
@@ -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);