@fivem-nui/svelte-components 0.0.0-stage → 1.0.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 +101 -2
- package/dist/components/actions/NuiButton.svelte +292 -0
- package/dist/components/actions/NuiButton.svelte.d.ts +60 -0
- package/dist/components/data/NuiBadge.svelte +277 -0
- package/dist/components/data/NuiBadge.svelte.d.ts +74 -0
- package/dist/components/data/NuiCodeBlock.svelte +256 -0
- package/dist/components/data/NuiCodeBlock.svelte.d.ts +49 -0
- package/dist/components/data/NuiList.svelte +341 -0
- package/dist/components/data/NuiList.svelte.d.ts +52 -0
- package/dist/components/data/NuiProgressBar.svelte +264 -0
- package/dist/components/data/NuiProgressBar.svelte.d.ts +51 -0
- package/dist/components/data/NuiRichText.svelte +260 -0
- package/dist/components/data/NuiRichText.svelte.d.ts +33 -0
- package/dist/components/data/NuiTable.svelte +470 -0
- package/dist/components/data/NuiTable.svelte.d.ts +53 -0
- package/dist/components/data/chart/NuiChart.svelte +384 -0
- package/dist/components/data/chart/NuiChart.svelte.d.ts +49 -0
- package/dist/components/data/chart/color.d.ts +9 -0
- package/dist/components/data/chart/color.js +78 -0
- package/dist/components/data/chart/index.d.ts +10 -0
- package/dist/components/data/chart/index.js +10 -0
- package/dist/components/data/chart/registry.d.ts +14 -0
- package/dist/components/data/chart/registry.js +53 -0
- package/dist/components/data/chart/renderers/bar.d.ts +8 -0
- package/dist/components/data/chart/renderers/bar.js +79 -0
- package/dist/components/data/chart/renderers/base.d.ts +18 -0
- package/dist/components/data/chart/renderers/base.js +10 -0
- package/dist/components/data/chart/renderers/line.d.ts +8 -0
- package/dist/components/data/chart/renderers/line.js +89 -0
- package/dist/components/data/chart/renderers/pie.d.ts +10 -0
- package/dist/components/data/chart/renderers/pie.js +55 -0
- package/dist/components/data/chart/scale.d.ts +22 -0
- package/dist/components/data/chart/scale.js +101 -0
- package/dist/components/data/chart/types.d.ts +84 -0
- package/dist/components/data/chart/types.js +1 -0
- package/dist/components/data/chart/validator.d.ts +10 -0
- package/dist/components/data/chart/validator.js +82 -0
- package/dist/components/forms/NuiCheckbox.svelte +218 -0
- package/dist/components/forms/NuiCheckbox.svelte.d.ts +37 -0
- package/dist/components/forms/NuiDatepicker.svelte +755 -0
- package/dist/components/forms/NuiDatepicker.svelte.d.ts +34 -0
- package/dist/components/forms/NuiFileInput.svelte +1047 -0
- package/dist/components/forms/NuiFileInput.svelte.d.ts +93 -0
- package/dist/components/forms/NuiFileInput.types.d.ts +28 -0
- package/dist/components/forms/NuiFileInput.types.js +1 -0
- package/dist/components/forms/NuiForm.svelte +391 -0
- package/dist/components/forms/NuiForm.svelte.d.ts +69 -0
- package/dist/components/forms/NuiInput.svelte +296 -0
- package/dist/components/forms/NuiInput.svelte.d.ts +62 -0
- package/dist/components/forms/NuiRadioGroup.svelte +238 -0
- package/dist/components/forms/NuiRadioGroup.svelte.d.ts +47 -0
- package/dist/components/forms/NuiSelect.svelte +819 -0
- package/dist/components/forms/NuiSelect.svelte.d.ts +49 -0
- package/dist/components/layout/NuiCard.svelte +314 -0
- package/dist/components/layout/NuiCard.svelte.d.ts +48 -0
- package/dist/components/layout/NuiCardGrid.svelte +98 -0
- package/dist/components/layout/NuiCardGrid.svelte.d.ts +22 -0
- package/dist/components/layout/NuiFlex.svelte +121 -0
- package/dist/components/layout/NuiFlex.svelte.d.ts +23 -0
- package/dist/components/layout/NuiGrid.svelte +66 -0
- package/dist/components/layout/NuiGrid.svelte.d.ts +14 -0
- package/dist/components/layout/NuiStack.svelte +138 -0
- package/dist/components/layout/NuiStack.svelte.d.ts +26 -0
- package/dist/components/navigation/NuiAccordion.svelte +323 -0
- package/dist/components/navigation/NuiAccordion.svelte.d.ts +38 -0
- package/dist/components/navigation/NuiBreadcrumbs.svelte +133 -0
- package/dist/components/navigation/NuiBreadcrumbs.svelte.d.ts +19 -0
- package/dist/components/navigation/NuiMenu.svelte +596 -0
- package/dist/components/navigation/NuiMenu.svelte.d.ts +28 -0
- package/dist/components/navigation/NuiPagination.svelte +306 -0
- package/dist/components/navigation/NuiPagination.svelte.d.ts +40 -0
- package/dist/components/navigation/NuiSidebar.svelte +186 -0
- package/dist/components/navigation/NuiSidebar.svelte.d.ts +26 -0
- package/dist/components/navigation/NuiTabs.svelte +249 -0
- package/dist/components/navigation/NuiTabs.svelte.d.ts +31 -0
- package/dist/components/overlay/NuiDialog.svelte +118 -0
- package/dist/components/overlay/NuiDialog.svelte.d.ts +34 -0
- package/dist/components/overlay/NuiModal.svelte +992 -0
- package/dist/components/overlay/NuiModal.svelte.d.ts +72 -0
- package/dist/components/overlay/NuiTooltip.svelte +159 -0
- package/dist/components/overlay/NuiTooltip.svelte.d.ts +13 -0
- package/dist/composables/useClickOutside.d.ts +43 -0
- package/dist/composables/useClickOutside.js +73 -0
- package/dist/composables/useFormValidation.d.ts +23 -0
- package/dist/composables/useFormValidation.js +72 -0
- package/dist/composables/useIdle.svelte.d.ts +18 -0
- package/dist/composables/useIdle.svelte.js +144 -0
- package/dist/composables/useKeyboardShortcut.svelte.d.ts +114 -0
- package/dist/composables/useKeyboardShortcut.svelte.js +153 -0
- package/dist/composables/useLocalStorage.svelte.d.ts +22 -0
- package/dist/composables/useLocalStorage.svelte.js +105 -0
- package/dist/composables/useMediaQuery.svelte.d.ts +51 -0
- package/dist/composables/useMediaQuery.svelte.js +80 -0
- package/dist/index.d.ts +46 -0
- package/dist/index.js +42 -0
- package/package.json +58 -3
- package/src/components/actions/NuiButton.svelte +292 -0
- package/src/components/data/NuiBadge.svelte +277 -0
- package/src/components/data/NuiCodeBlock.svelte +256 -0
- package/src/components/data/NuiList.svelte +341 -0
- package/src/components/data/NuiProgressBar.svelte +264 -0
- package/src/components/data/NuiRichText.svelte +260 -0
- package/src/components/data/NuiTable.svelte +470 -0
- package/src/components/data/chart/NuiChart.svelte +384 -0
- package/src/components/data/chart/color.ts +83 -0
- package/src/components/data/chart/index.ts +10 -0
- package/src/components/data/chart/registry.ts +71 -0
- package/src/components/data/chart/renderers/bar.ts +90 -0
- package/src/components/data/chart/renderers/base.ts +32 -0
- package/src/components/data/chart/renderers/line.ts +99 -0
- package/src/components/data/chart/renderers/pie.ts +66 -0
- package/src/components/data/chart/scale.ts +139 -0
- package/src/components/data/chart/types.ts +98 -0
- package/src/components/data/chart/validator.ts +98 -0
- package/src/components/forms/NuiCheckbox.svelte +218 -0
- package/src/components/forms/NuiDatepicker.svelte +755 -0
- package/src/components/forms/NuiFileInput.svelte +1047 -0
- package/src/components/forms/NuiFileInput.types.ts +29 -0
- package/src/components/forms/NuiForm.svelte +391 -0
- package/src/components/forms/NuiInput.svelte +296 -0
- package/src/components/forms/NuiRadioGroup.svelte +238 -0
- package/src/components/forms/NuiSelect.svelte +819 -0
- package/src/components/layout/NuiCard.svelte +314 -0
- package/src/components/layout/NuiCardGrid.svelte +98 -0
- package/src/components/layout/NuiFlex.svelte +121 -0
- package/src/components/layout/NuiGrid.svelte +66 -0
- package/src/components/layout/NuiStack.svelte +138 -0
- package/src/components/navigation/NuiAccordion.svelte +323 -0
- package/src/components/navigation/NuiBreadcrumbs.svelte +133 -0
- package/src/components/navigation/NuiMenu.svelte +596 -0
- package/src/components/navigation/NuiPagination.svelte +306 -0
- package/src/components/navigation/NuiSidebar.svelte +186 -0
- package/src/components/navigation/NuiTabs.svelte +249 -0
- package/src/components/overlay/NuiDialog.svelte +118 -0
- package/src/components/overlay/NuiModal.svelte +992 -0
- package/src/components/overlay/NuiTooltip.svelte +159 -0
- package/src/composables/useClickOutside.ts +132 -0
- package/src/composables/useFormValidation.ts +105 -0
- package/src/composables/useIdle.svelte.ts +191 -0
- package/src/composables/useKeyboardShortcut.svelte.ts +278 -0
- package/src/composables/useLocalStorage.svelte.ts +137 -0
- package/src/composables/useMediaQuery.svelte.ts +102 -0
- package/src/index.ts +66 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component NuiChart
|
|
3
|
+
Canvas chart component (bar, line, pie, doughnut) for FiveM NUI.
|
|
4
|
+
-->
|
|
5
|
+
<script module lang="ts">
|
|
6
|
+
export type {
|
|
7
|
+
ChartData,
|
|
8
|
+
ChartDataset,
|
|
9
|
+
ChartOptions,
|
|
10
|
+
ChartType,
|
|
11
|
+
} from "./types";
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<script lang="ts">
|
|
15
|
+
import { onMount } from "svelte";
|
|
16
|
+
import { nui } from "@fivem-nui/svelte";
|
|
17
|
+
import type { SvelteNuiClient } from "@fivem-nui/svelte";
|
|
18
|
+
import type {
|
|
19
|
+
ChartData,
|
|
20
|
+
ChartDataset,
|
|
21
|
+
ChartOptions,
|
|
22
|
+
ChartType,
|
|
23
|
+
} from "./types";
|
|
24
|
+
import { getColor } from "./color";
|
|
25
|
+
import { validateChartData } from "./validator";
|
|
26
|
+
import { renderChart } from "./registry";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Props for {@link NuiChart} component.
|
|
30
|
+
*/
|
|
31
|
+
interface Props {
|
|
32
|
+
/**
|
|
33
|
+
* Chart type determining the rendering strategy.
|
|
34
|
+
* Supported built-in types: `'bar'`, `'line'`, `'pie'`, `'doughnut'` or custom types.
|
|
35
|
+
*/
|
|
36
|
+
type: ChartType;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Source chart data passed from parent.
|
|
40
|
+
*/
|
|
41
|
+
data: ChartData;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Container width in pixels or CSS units.
|
|
45
|
+
* @defaultValue '100%'
|
|
46
|
+
*/
|
|
47
|
+
width?: number | string;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Canvas height in pixels or CSS units.
|
|
51
|
+
* @defaultValue '300px'
|
|
52
|
+
*/
|
|
53
|
+
height?: number | string;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Optional configuration for grid, Y-scale, legend position, and accessibility.
|
|
57
|
+
* @defaultValue {}
|
|
58
|
+
*/
|
|
59
|
+
options?: ChartOptions;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Optional NUI event name (from game / C#).
|
|
63
|
+
* When provided, automatically listens for chart updates from NUI.
|
|
64
|
+
*/
|
|
65
|
+
eventName?: string;
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* SvelteNuiClient instance to listen for game events.
|
|
69
|
+
* @defaultValue singleton `nui` from `@fivem-nui/svelte`
|
|
70
|
+
*/
|
|
71
|
+
client?: SvelteNuiClient;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
let {
|
|
75
|
+
type,
|
|
76
|
+
data,
|
|
77
|
+
width = "100%",
|
|
78
|
+
height = "300px",
|
|
79
|
+
options = {},
|
|
80
|
+
eventName,
|
|
81
|
+
client = nui,
|
|
82
|
+
}: Props = $props();
|
|
83
|
+
|
|
84
|
+
let canvasEl = $state<HTMLCanvasElement | null>(null);
|
|
85
|
+
let containerEl = $state<HTMLDivElement | null>(null);
|
|
86
|
+
let rafId: number | null = null;
|
|
87
|
+
|
|
88
|
+
// Data sources: separation of parent props, NUI events, and local visibility state
|
|
89
|
+
let remoteData = $state<ChartData | null>(null);
|
|
90
|
+
let hiddenIndices = $state<Record<number, boolean>>({});
|
|
91
|
+
let lastPropsData: ChartData | null = null;
|
|
92
|
+
|
|
93
|
+
// New parent props reset remote overrides and local hidden states
|
|
94
|
+
$effect(() => {
|
|
95
|
+
if (data !== lastPropsData) {
|
|
96
|
+
lastPropsData = data;
|
|
97
|
+
remoteData = null;
|
|
98
|
+
hiddenIndices = {};
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// Listen for NUI updates with runtime validation
|
|
103
|
+
$effect(() => {
|
|
104
|
+
if (!eventName || !client) return;
|
|
105
|
+
const unsub = client.on(eventName, (payload: unknown) => {
|
|
106
|
+
const validation = validateChartData(payload);
|
|
107
|
+
if (validation.valid && validation.data) {
|
|
108
|
+
remoteData = validation.data;
|
|
109
|
+
hiddenIndices = {};
|
|
110
|
+
} else {
|
|
111
|
+
console.warn(
|
|
112
|
+
`[NuiChart] Rejected invalid payload from event '${eventName}':`,
|
|
113
|
+
validation.error,
|
|
114
|
+
payload,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
return () => unsub();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Effective data combining remote overrides and local visibility toggles
|
|
122
|
+
const activeRawData = $derived(remoteData ?? data);
|
|
123
|
+
const currentData = $derived<ChartData>({
|
|
124
|
+
labels: activeRawData?.labels ?? [],
|
|
125
|
+
datasets: (activeRawData?.datasets ?? []).map((ds, idx) => ({
|
|
126
|
+
...ds,
|
|
127
|
+
hidden: hiddenIndices[idx] ?? ds.hidden ?? false,
|
|
128
|
+
})),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// Common pie dataset used in renderer and legend
|
|
132
|
+
const activePieDataset = $derived(
|
|
133
|
+
currentData.datasets.find((d) => !d.hidden) ?? currentData.datasets[0],
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Schedules a render on the next animation frame.
|
|
138
|
+
*/
|
|
139
|
+
function scheduleRender() {
|
|
140
|
+
if (typeof window === "undefined") return;
|
|
141
|
+
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
142
|
+
rafId = requestAnimationFrame(() => {
|
|
143
|
+
rafId = null;
|
|
144
|
+
if (!canvasEl) return;
|
|
145
|
+
const rect = canvasEl.getBoundingClientRect();
|
|
146
|
+
const w = rect.width || (typeof width === "number" ? width : 400);
|
|
147
|
+
const h = rect.height || (typeof height === "number" ? height : 300);
|
|
148
|
+
renderChart(canvasEl, w, h, type, currentData, options);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
$effect(() => {
|
|
153
|
+
void type;
|
|
154
|
+
void currentData;
|
|
155
|
+
void options;
|
|
156
|
+
void width;
|
|
157
|
+
void height;
|
|
158
|
+
scheduleRender();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
onMount(() => {
|
|
162
|
+
scheduleRender();
|
|
163
|
+
|
|
164
|
+
if (containerEl && typeof ResizeObserver !== "undefined") {
|
|
165
|
+
const ro = new ResizeObserver((entries) => {
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
if (entry.contentRect.width > 0 && entry.contentRect.height > 0) {
|
|
168
|
+
scheduleRender();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
ro.observe(containerEl);
|
|
173
|
+
return () => {
|
|
174
|
+
ro.disconnect();
|
|
175
|
+
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return () => {
|
|
180
|
+
if (rafId !== null) cancelAnimationFrame(rafId);
|
|
181
|
+
};
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const legendPos = $derived(
|
|
185
|
+
options.legend?.position ?? options.plugins?.legend?.position ?? "bottom",
|
|
186
|
+
);
|
|
187
|
+
const showLegend = $derived(legendPos !== "none");
|
|
188
|
+
|
|
189
|
+
/** Accessible description for screen readers. */
|
|
190
|
+
const resolvedAriaLabel = $derived.by(() => {
|
|
191
|
+
if (options.ariaLabel) return options.ariaLabel;
|
|
192
|
+
const labels = currentData.labels || [];
|
|
193
|
+
const labelsSummary =
|
|
194
|
+
labels.length > 5
|
|
195
|
+
? `${labels.slice(0, 5).join(", ")} and ${labels.length - 5} more`
|
|
196
|
+
: labels.join(", ");
|
|
197
|
+
return `Chart of type ${type}.${labels.length ? ` Categories: ${labelsSummary}` : ""}`;
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Toggles dataset visibility locally without mutating source data.
|
|
202
|
+
*/
|
|
203
|
+
function toggleDataset(index: number) {
|
|
204
|
+
const isCurrentlyHidden = currentData.datasets[index]?.hidden ?? false;
|
|
205
|
+
hiddenIndices[index] = !isCurrentlyHidden;
|
|
206
|
+
}
|
|
207
|
+
</script>
|
|
208
|
+
|
|
209
|
+
<div
|
|
210
|
+
class="nui-chart-container"
|
|
211
|
+
bind:this={containerEl}
|
|
212
|
+
style="width: {typeof width === 'number' ? `${width}px` : width};"
|
|
213
|
+
>
|
|
214
|
+
{#if showLegend && legendPos === "top"}
|
|
215
|
+
<div class="nui-chart__legend nui-chart__legend--top">
|
|
216
|
+
{#if type === "pie" || type === "doughnut"}
|
|
217
|
+
{#if activePieDataset && !activePieDataset.hidden}
|
|
218
|
+
{#each currentData.labels || [] as label, idx}
|
|
219
|
+
{#if (activePieDataset.data[idx] ?? 0) > 0}
|
|
220
|
+
<div class="nui-chart__legend-item">
|
|
221
|
+
<span
|
|
222
|
+
class="nui-chart__legend-color"
|
|
223
|
+
style="background-color: {getColor(
|
|
224
|
+
activePieDataset.backgroundColor,
|
|
225
|
+
idx,
|
|
226
|
+
)}"
|
|
227
|
+
></span>
|
|
228
|
+
<span class="nui-chart__legend-label">{label}</span>
|
|
229
|
+
</div>
|
|
230
|
+
{/if}
|
|
231
|
+
{/each}
|
|
232
|
+
{/if}
|
|
233
|
+
{:else}
|
|
234
|
+
{#each currentData.datasets as ds, idx}
|
|
235
|
+
<button
|
|
236
|
+
type="button"
|
|
237
|
+
class="nui-chart__legend-btn"
|
|
238
|
+
class:nui-chart__legend-btn--hidden={ds.hidden}
|
|
239
|
+
aria-pressed={!ds.hidden}
|
|
240
|
+
aria-label="{ds.hidden ? 'Show' : 'Hide'} series: {ds.label}"
|
|
241
|
+
onclick={() => toggleDataset(idx)}
|
|
242
|
+
>
|
|
243
|
+
<span
|
|
244
|
+
class="nui-chart__legend-color"
|
|
245
|
+
style="background-color: {getColor(
|
|
246
|
+
ds.borderColor || ds.backgroundColor,
|
|
247
|
+
idx,
|
|
248
|
+
)}"
|
|
249
|
+
></span>
|
|
250
|
+
<span class="nui-chart__legend-label">{ds.label}</span>
|
|
251
|
+
</button>
|
|
252
|
+
{/each}
|
|
253
|
+
{/if}
|
|
254
|
+
</div>
|
|
255
|
+
{/if}
|
|
256
|
+
|
|
257
|
+
<canvas
|
|
258
|
+
bind:this={canvasEl}
|
|
259
|
+
class="nui-chart"
|
|
260
|
+
role="img"
|
|
261
|
+
aria-label={resolvedAriaLabel}
|
|
262
|
+
style="width: 100%; height: {typeof height === 'number'
|
|
263
|
+
? `${height}px`
|
|
264
|
+
: height};"
|
|
265
|
+
>
|
|
266
|
+
<p>{resolvedAriaLabel}</p>
|
|
267
|
+
</canvas>
|
|
268
|
+
|
|
269
|
+
{#if showLegend && legendPos !== "top"}
|
|
270
|
+
<div class="nui-chart__legend nui-chart__legend--bottom">
|
|
271
|
+
{#if type === "pie" || type === "doughnut"}
|
|
272
|
+
{#if activePieDataset && !activePieDataset.hidden}
|
|
273
|
+
{#each currentData.labels || [] as label, idx}
|
|
274
|
+
{#if (activePieDataset.data[idx] ?? 0) > 0}
|
|
275
|
+
<div class="nui-chart__legend-item">
|
|
276
|
+
<span
|
|
277
|
+
class="nui-chart__legend-color"
|
|
278
|
+
style="background-color: {getColor(
|
|
279
|
+
activePieDataset.backgroundColor,
|
|
280
|
+
idx,
|
|
281
|
+
)}"
|
|
282
|
+
></span>
|
|
283
|
+
<span class="nui-chart__legend-label">{label}</span>
|
|
284
|
+
</div>
|
|
285
|
+
{/if}
|
|
286
|
+
{/each}
|
|
287
|
+
{/if}
|
|
288
|
+
{:else}
|
|
289
|
+
{#each currentData.datasets as ds, idx}
|
|
290
|
+
<button
|
|
291
|
+
type="button"
|
|
292
|
+
class="nui-chart__legend-btn"
|
|
293
|
+
class:nui-chart__legend-btn--hidden={ds.hidden}
|
|
294
|
+
aria-pressed={!ds.hidden}
|
|
295
|
+
aria-label="{ds.hidden ? 'Show' : 'Hide'} series: {ds.label}"
|
|
296
|
+
onclick={() => toggleDataset(idx)}
|
|
297
|
+
>
|
|
298
|
+
<span
|
|
299
|
+
class="nui-chart__legend-color"
|
|
300
|
+
style="background-color: {getColor(
|
|
301
|
+
ds.borderColor || ds.backgroundColor,
|
|
302
|
+
idx,
|
|
303
|
+
)}"
|
|
304
|
+
></span>
|
|
305
|
+
<span class="nui-chart__legend-label">{ds.label}</span>
|
|
306
|
+
</button>
|
|
307
|
+
{/each}
|
|
308
|
+
{/if}
|
|
309
|
+
</div>
|
|
310
|
+
{/if}
|
|
311
|
+
</div>
|
|
312
|
+
|
|
313
|
+
<style>
|
|
314
|
+
.nui-chart-container {
|
|
315
|
+
position: relative;
|
|
316
|
+
display: flex;
|
|
317
|
+
flex-direction: column;
|
|
318
|
+
gap: 0.75rem;
|
|
319
|
+
font-family: inherit;
|
|
320
|
+
box-sizing: border-box;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
.nui-chart {
|
|
324
|
+
display: block;
|
|
325
|
+
border-radius: var(--nui-radius, 0.375rem);
|
|
326
|
+
box-sizing: border-box;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
.nui-chart__legend {
|
|
330
|
+
display: flex;
|
|
331
|
+
flex-wrap: wrap;
|
|
332
|
+
align-items: center;
|
|
333
|
+
justify-content: center;
|
|
334
|
+
gap: 1rem;
|
|
335
|
+
padding: 0.25rem 0;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.nui-chart__legend-item,
|
|
339
|
+
.nui-chart__legend-btn {
|
|
340
|
+
display: inline-flex;
|
|
341
|
+
align-items: center;
|
|
342
|
+
gap: 0.375rem;
|
|
343
|
+
font-size: 0.75rem;
|
|
344
|
+
color: var(--nui-muted, #a1a1aa);
|
|
345
|
+
line-height: 1;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.nui-chart__legend-btn {
|
|
349
|
+
background: transparent;
|
|
350
|
+
border: none;
|
|
351
|
+
padding: 0.125rem 0.25rem;
|
|
352
|
+
border-radius: 0.25rem;
|
|
353
|
+
cursor: pointer;
|
|
354
|
+
font: inherit;
|
|
355
|
+
transition:
|
|
356
|
+
opacity 0.15s ease,
|
|
357
|
+
text-decoration 0.15s ease;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
.nui-chart__legend-btn:hover {
|
|
361
|
+
opacity: 0.8;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
.nui-chart__legend-btn:focus-visible {
|
|
365
|
+
outline: 2px solid var(--nui-primary, #6366f1);
|
|
366
|
+
outline-offset: 2px;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
.nui-chart__legend-btn--hidden {
|
|
370
|
+
opacity: 0.4;
|
|
371
|
+
text-decoration: line-through;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
.nui-chart__legend-color {
|
|
375
|
+
width: 0.75rem;
|
|
376
|
+
height: 0.75rem;
|
|
377
|
+
border-radius: 0.125rem;
|
|
378
|
+
flex-shrink: 0;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
.nui-chart__legend-label {
|
|
382
|
+
line-height: 1;
|
|
383
|
+
}
|
|
384
|
+
</style>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export const DEFAULT_COLORS = [
|
|
2
|
+
'#6366f1', // indigo
|
|
3
|
+
'#06b6d4', // cyan
|
|
4
|
+
'#10b981', // emerald
|
|
5
|
+
'#f59e0b', // amber
|
|
6
|
+
'#ef4444', // red
|
|
7
|
+
'#8b5cf6', // purple
|
|
8
|
+
'#ec4899', // pink
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
let helperCtx: CanvasRenderingContext2D | null = null;
|
|
12
|
+
|
|
13
|
+
function getHelperCtx(): CanvasRenderingContext2D | null {
|
|
14
|
+
if (typeof document === 'undefined') return null;
|
|
15
|
+
if (!helperCtx) {
|
|
16
|
+
const canvas = document.createElement('canvas');
|
|
17
|
+
canvas.width = 1;
|
|
18
|
+
canvas.height = 1;
|
|
19
|
+
helperCtx = canvas.getContext('2d', { willReadFrequently: true });
|
|
20
|
+
}
|
|
21
|
+
return helperCtx;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Returns valid color string from dataset property or fallback palette.
|
|
26
|
+
*/
|
|
27
|
+
export function getColor(item: string | string[] | undefined, index: number): string {
|
|
28
|
+
const safeIdx = Number.isFinite(index) ? Math.floor(Math.abs(index)) : 0;
|
|
29
|
+
if (Array.isArray(item) && item.length > 0) {
|
|
30
|
+
const color = item[safeIdx % item.length];
|
|
31
|
+
if (typeof color === 'string' && color.trim()) {
|
|
32
|
+
return color.trim();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
if (typeof item === 'string' && item.trim()) {
|
|
36
|
+
return item.trim();
|
|
37
|
+
}
|
|
38
|
+
return DEFAULT_COLORS[safeIdx % DEFAULT_COLORS.length];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Converts color (hex, rgb, hsl, named) to rgba(r, g, b, alpha).
|
|
43
|
+
*/
|
|
44
|
+
export function colorToRgba(color: string, alpha: number): string {
|
|
45
|
+
const a = Math.max(0, Math.min(1, Number.isFinite(alpha) ? alpha : 1));
|
|
46
|
+
if (!color || typeof color !== 'string') return `rgba(99, 102, 241, ${a})`;
|
|
47
|
+
const trimmed = color.trim();
|
|
48
|
+
|
|
49
|
+
// Fast path for hex formats: #RGB or #RRGGBB
|
|
50
|
+
if (/^#([0-9a-f]{3}|[0-9a-f]{6})$/i.test(trimmed)) {
|
|
51
|
+
let hex = trimmed.slice(1);
|
|
52
|
+
if (hex.length === 3) {
|
|
53
|
+
hex = hex.split('').map((c) => c + c).join('');
|
|
54
|
+
}
|
|
55
|
+
const r = parseInt(hex.slice(0, 2), 16);
|
|
56
|
+
const g = parseInt(hex.slice(2, 4), 16);
|
|
57
|
+
const b = parseInt(hex.slice(4, 6), 16);
|
|
58
|
+
return `rgba(${r}, ${g}, ${b}, ${a})`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Fast path for rgb(...) or rgba(...)
|
|
62
|
+
const rgbMatch = trimmed.match(/^rgba?\(\s*(\d{1,3})\s*[, ]\s*(\d{1,3})\s*[, ]\s*(\d{1,3})/i);
|
|
63
|
+
if (rgbMatch) {
|
|
64
|
+
return `rgba(${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}, ${a})`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Canvas parser for named colors, hsl, etc.
|
|
68
|
+
const ctx = getHelperCtx();
|
|
69
|
+
if (ctx) {
|
|
70
|
+
try {
|
|
71
|
+
ctx.clearRect(0, 0, 1, 1);
|
|
72
|
+
ctx.fillStyle = '#000000';
|
|
73
|
+
ctx.fillStyle = trimmed;
|
|
74
|
+
ctx.fillRect(0, 0, 1, 1);
|
|
75
|
+
const pixel = ctx.getImageData(0, 0, 1, 1).data;
|
|
76
|
+
return `rgba(${pixel[0]}, ${pixel[1]}, ${pixel[2]}, ${a})`;
|
|
77
|
+
} catch {
|
|
78
|
+
// Fallback on environment error
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return `rgba(99, 102, 241, ${a})`;
|
|
83
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export { default as NuiChart } from './NuiChart.svelte';
|
|
2
|
+
export * from './types';
|
|
3
|
+
export * from './color';
|
|
4
|
+
export * from './validator';
|
|
5
|
+
export * from './scale';
|
|
6
|
+
export * from './registry';
|
|
7
|
+
export * from './renderers/base';
|
|
8
|
+
export * from './renderers/bar';
|
|
9
|
+
export * from './renderers/line';
|
|
10
|
+
export * from './renderers/pie';
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ChartData, ChartOptions, ChartType } from './types';
|
|
2
|
+
import type { ChartRenderer, RenderContext } from './renderers/base';
|
|
3
|
+
import { drawMessage } from './renderers/base';
|
|
4
|
+
import { BarChartRenderer } from './renderers/bar';
|
|
5
|
+
import { LineChartRenderer } from './renderers/line';
|
|
6
|
+
import { PieChartRenderer } from './renderers/pie';
|
|
7
|
+
|
|
8
|
+
const renderers: Map<string, ChartRenderer> = new Map<string, ChartRenderer>([
|
|
9
|
+
['bar', new BarChartRenderer()],
|
|
10
|
+
['line', new LineChartRenderer()],
|
|
11
|
+
['pie', new PieChartRenderer(false)],
|
|
12
|
+
['doughnut', new PieChartRenderer(true)],
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Registers or overrides a chart renderer.
|
|
17
|
+
*/
|
|
18
|
+
export function registerChartRenderer(type: string, renderer: ChartRenderer): void {
|
|
19
|
+
renderers.set(type, renderer);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Returns the registered renderer for given chart type.
|
|
24
|
+
*/
|
|
25
|
+
export function getChartRenderer(type: ChartType | string): ChartRenderer | undefined {
|
|
26
|
+
return renderers.get(type);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Renders chart on the given canvas element with hidpi scaling.
|
|
31
|
+
*/
|
|
32
|
+
export function renderChart(
|
|
33
|
+
canvasEl: HTMLCanvasElement,
|
|
34
|
+
w: number,
|
|
35
|
+
h: number,
|
|
36
|
+
type: ChartType | string,
|
|
37
|
+
currentData: ChartData | undefined,
|
|
38
|
+
options: ChartOptions = {}
|
|
39
|
+
): void {
|
|
40
|
+
const ctx = canvasEl.getContext('2d');
|
|
41
|
+
if (!ctx) return;
|
|
42
|
+
|
|
43
|
+
const dpr = typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1;
|
|
44
|
+
canvasEl.width = Math.floor(w * dpr);
|
|
45
|
+
canvasEl.height = Math.floor(h * dpr);
|
|
46
|
+
|
|
47
|
+
ctx.resetTransform?.();
|
|
48
|
+
ctx.scale(dpr, dpr);
|
|
49
|
+
ctx.clearRect(0, 0, w, h);
|
|
50
|
+
|
|
51
|
+
if (!currentData || !currentData.datasets || currentData.datasets.length === 0) {
|
|
52
|
+
drawMessage(ctx, w, h, 'No data to display');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const renderer = getChartRenderer(type);
|
|
57
|
+
if (!renderer) {
|
|
58
|
+
drawMessage(ctx, w, h, `Unsupported chart type: ${type}`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const context: RenderContext = {
|
|
63
|
+
canvas: canvasEl,
|
|
64
|
+
ctx,
|
|
65
|
+
width: w,
|
|
66
|
+
height: h,
|
|
67
|
+
dpr,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
renderer.render(context, currentData, options);
|
|
71
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { ChartData, ChartOptions } from '../types';
|
|
2
|
+
import type { ChartRenderer, RenderContext } from './base';
|
|
3
|
+
import { drawMessage } from './base';
|
|
4
|
+
import { getColor } from '../color';
|
|
5
|
+
import { calculateScale, drawGridAndYLabels, DEFAULT_CHART_PADDING } from '../scale';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Renderer for bar and column charts.
|
|
9
|
+
*/
|
|
10
|
+
export class BarChartRenderer implements ChartRenderer {
|
|
11
|
+
render(context: RenderContext, data: ChartData, options: ChartOptions): void {
|
|
12
|
+
const { ctx, width, height } = context;
|
|
13
|
+
const padding = DEFAULT_CHART_PADDING;
|
|
14
|
+
const chartW = Math.max(10, width - padding.left - padding.right);
|
|
15
|
+
const chartH = Math.max(10, height - padding.top - padding.bottom);
|
|
16
|
+
|
|
17
|
+
const labels = data.labels || [];
|
|
18
|
+
const datasets = data.datasets.filter((d) => !d.hidden);
|
|
19
|
+
if (datasets.length === 0) {
|
|
20
|
+
drawMessage(ctx, width, height, 'All datasets are hidden');
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const scale = calculateScale(datasets, options, chartH, padding.top);
|
|
25
|
+
drawGridAndYLabels(ctx, padding, chartW, chartH, scale, options);
|
|
26
|
+
|
|
27
|
+
const showLabels = options.showLabels !== false;
|
|
28
|
+
const groupCount = Math.max(labels.length, ...datasets.map((d) => d.data.length), 1);
|
|
29
|
+
const groupW = chartW / groupCount;
|
|
30
|
+
const totalBarWidth = (groupW * 0.7) / datasets.length;
|
|
31
|
+
const barGap = Math.min(4, totalBarWidth * 0.2);
|
|
32
|
+
|
|
33
|
+
for (let g = 0; g < groupCount; g++) {
|
|
34
|
+
const groupX = padding.left + g * groupW;
|
|
35
|
+
|
|
36
|
+
if (showLabels && labels[g]) {
|
|
37
|
+
ctx.fillStyle = '#a1a1aa';
|
|
38
|
+
ctx.textAlign = 'center';
|
|
39
|
+
ctx.textBaseline = 'top';
|
|
40
|
+
ctx.fillText(labels[g], groupX + groupW / 2, padding.top + chartH + 8);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
datasets.forEach((ds, dsIndex) => {
|
|
44
|
+
const val = ds.data[g];
|
|
45
|
+
if (val === null || val === undefined || !Number.isFinite(val)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const clampedVal = Math.max(scale.minVal, Math.min(scale.maxVal, val));
|
|
50
|
+
const valY = padding.top + chartH - ((clampedVal - scale.minVal) / scale.range) * chartH;
|
|
51
|
+
const barX = groupX + (groupW - totalBarWidth * datasets.length) / 2 + dsIndex * totalBarWidth;
|
|
52
|
+
|
|
53
|
+
let barY: number;
|
|
54
|
+
let barH: number;
|
|
55
|
+
if (clampedVal >= 0) {
|
|
56
|
+
barY = valY;
|
|
57
|
+
barH = scale.baselineY - valY;
|
|
58
|
+
} else {
|
|
59
|
+
barY = scale.baselineY;
|
|
60
|
+
barH = valY - scale.baselineY;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (barH <= 0) return;
|
|
64
|
+
|
|
65
|
+
const barWidth = Math.max(1, totalBarWidth - barGap);
|
|
66
|
+
ctx.fillStyle = getColor(ds.backgroundColor, dsIndex);
|
|
67
|
+
|
|
68
|
+
const r = Math.min(4, barH / 2, barWidth / 2);
|
|
69
|
+
ctx.beginPath();
|
|
70
|
+
if (clampedVal >= 0) {
|
|
71
|
+
ctx.moveTo(barX, scale.baselineY);
|
|
72
|
+
ctx.lineTo(barX, barY + r);
|
|
73
|
+
ctx.quadraticCurveTo(barX, barY, barX + r, barY);
|
|
74
|
+
ctx.lineTo(barX + barWidth - r, barY);
|
|
75
|
+
ctx.quadraticCurveTo(barX + barWidth, barY, barX + barWidth, barY + r);
|
|
76
|
+
ctx.lineTo(barX + barWidth, scale.baselineY);
|
|
77
|
+
} else {
|
|
78
|
+
ctx.moveTo(barX, scale.baselineY);
|
|
79
|
+
ctx.lineTo(barX, barY + barH - r);
|
|
80
|
+
ctx.quadraticCurveTo(barX, barY + barH, barX + r, barY + barH);
|
|
81
|
+
ctx.lineTo(barX + barWidth - r, barY + barH);
|
|
82
|
+
ctx.quadraticCurveTo(barX + barWidth, barY + barH, barX + barWidth, barY + barH - r);
|
|
83
|
+
ctx.lineTo(barX + barWidth, scale.baselineY);
|
|
84
|
+
}
|
|
85
|
+
ctx.closePath();
|
|
86
|
+
ctx.fill();
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ChartData, ChartOptions } from '../types';
|
|
2
|
+
|
|
3
|
+
export interface RenderContext {
|
|
4
|
+
canvas: HTMLCanvasElement;
|
|
5
|
+
ctx: CanvasRenderingContext2D;
|
|
6
|
+
width: number;
|
|
7
|
+
height: number;
|
|
8
|
+
dpr: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Canvas chart renderer interface.
|
|
13
|
+
*/
|
|
14
|
+
export interface ChartRenderer {
|
|
15
|
+
render(context: RenderContext, data: ChartData, options: ChartOptions): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Draws centered text message on the canvas (eg. empty state).
|
|
20
|
+
*/
|
|
21
|
+
export function drawMessage(
|
|
22
|
+
ctx: CanvasRenderingContext2D,
|
|
23
|
+
width: number,
|
|
24
|
+
height: number,
|
|
25
|
+
text: string
|
|
26
|
+
): void {
|
|
27
|
+
ctx.fillStyle = '#71717a';
|
|
28
|
+
ctx.font = '13px sans-serif';
|
|
29
|
+
ctx.textAlign = 'center';
|
|
30
|
+
ctx.textBaseline = 'middle';
|
|
31
|
+
ctx.fillText(text, width / 2, height / 2);
|
|
32
|
+
}
|