@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,99 @@
|
|
|
1
|
+
import type { ChartData, ChartOptions } from '../types';
|
|
2
|
+
import type { ChartRenderer, RenderContext } from './base';
|
|
3
|
+
import { drawMessage } from './base';
|
|
4
|
+
import { getColor, colorToRgba } from '../color';
|
|
5
|
+
import { calculateScale, drawGridAndYLabels, DEFAULT_CHART_PADDING } from '../scale';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Renderer for line charts.
|
|
9
|
+
*/
|
|
10
|
+
export class LineChartRenderer 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 pointCount = Math.max(labels.length, ...datasets.map((d) => d.data.length), 1);
|
|
29
|
+
const stepX = pointCount > 1 ? chartW / (pointCount - 1) : chartW / 2;
|
|
30
|
+
|
|
31
|
+
if (showLabels) {
|
|
32
|
+
for (let i = 0; i < pointCount; i++) {
|
|
33
|
+
if (labels[i]) {
|
|
34
|
+
const x = padding.left + i * stepX;
|
|
35
|
+
ctx.fillStyle = '#a1a1aa';
|
|
36
|
+
ctx.textAlign = 'center';
|
|
37
|
+
ctx.textBaseline = 'top';
|
|
38
|
+
ctx.fillText(labels[i], x, padding.top + chartH + 8);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
datasets.forEach((ds, dsIndex) => {
|
|
44
|
+
const color = getColor(ds.borderColor || ds.backgroundColor, dsIndex);
|
|
45
|
+
ctx.strokeStyle = color;
|
|
46
|
+
ctx.lineWidth = Math.max(0, ds.borderWidth ?? 2);
|
|
47
|
+
|
|
48
|
+
const segments: { x: number; y: number }[][] = [];
|
|
49
|
+
let currentSegment: { x: number; y: number }[] = [];
|
|
50
|
+
|
|
51
|
+
for (let i = 0; i < pointCount; i++) {
|
|
52
|
+
const val = ds.data[i];
|
|
53
|
+
if (val !== null && val !== undefined && Number.isFinite(val)) {
|
|
54
|
+
const safeVal = Math.max(scale.minVal, Math.min(scale.maxVal, val));
|
|
55
|
+
const x = padding.left + i * stepX;
|
|
56
|
+
const y = padding.top + chartH - ((safeVal - scale.minVal) / scale.range) * chartH;
|
|
57
|
+
currentSegment.push({ x, y });
|
|
58
|
+
} else {
|
|
59
|
+
if (currentSegment.length > 0) {
|
|
60
|
+
segments.push(currentSegment);
|
|
61
|
+
currentSegment = [];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (currentSegment.length > 0) {
|
|
66
|
+
segments.push(currentSegment);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
segments.forEach((segment) => {
|
|
70
|
+
if (segment.length === 0) return;
|
|
71
|
+
|
|
72
|
+
ctx.beginPath();
|
|
73
|
+
segment.forEach((p, idx) => {
|
|
74
|
+
if (idx === 0) ctx.moveTo(p.x, p.y);
|
|
75
|
+
else ctx.lineTo(p.x, p.y);
|
|
76
|
+
});
|
|
77
|
+
ctx.stroke();
|
|
78
|
+
|
|
79
|
+
// Area fill clipped to baselineY
|
|
80
|
+
ctx.lineTo(segment[segment.length - 1].x, scale.baselineY);
|
|
81
|
+
ctx.lineTo(segment[0].x, scale.baselineY);
|
|
82
|
+
ctx.closePath();
|
|
83
|
+
ctx.fillStyle = colorToRgba(color, 0.1);
|
|
84
|
+
ctx.fill();
|
|
85
|
+
|
|
86
|
+
// Render point markers
|
|
87
|
+
ctx.fillStyle = '#ffffff';
|
|
88
|
+
ctx.strokeStyle = color;
|
|
89
|
+
ctx.lineWidth = 2;
|
|
90
|
+
segment.forEach((p) => {
|
|
91
|
+
ctx.beginPath();
|
|
92
|
+
ctx.arc(p.x, p.y, 3.5, 0, Math.PI * 2);
|
|
93
|
+
ctx.fill();
|
|
94
|
+
ctx.stroke();
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
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
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Renderer for pie and doughnut charts.
|
|
8
|
+
*/
|
|
9
|
+
export class PieChartRenderer implements ChartRenderer {
|
|
10
|
+
constructor(private readonly isDoughnut: boolean = false) {}
|
|
11
|
+
|
|
12
|
+
render(context: RenderContext, data: ChartData, _options: ChartOptions): void {
|
|
13
|
+
const { ctx, width, height } = context;
|
|
14
|
+
const cx = width / 2;
|
|
15
|
+
const cy = height / 2;
|
|
16
|
+
const radius = Math.max(10, Math.min(width, height) / 2 - 20);
|
|
17
|
+
|
|
18
|
+
// Pie chart uses the first visible dataset
|
|
19
|
+
const dataset = data.datasets.find((d) => !d.hidden) ?? data.datasets[0];
|
|
20
|
+
if (!dataset || dataset.hidden || !dataset.data || dataset.data.length === 0) {
|
|
21
|
+
drawMessage(ctx, width, height, 'Dataset is hidden or empty');
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const positiveValues = dataset.data.map((v) =>
|
|
26
|
+
typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : 0
|
|
27
|
+
);
|
|
28
|
+
const total = positiveValues.reduce((acc, v) => acc + v, 0);
|
|
29
|
+
|
|
30
|
+
if (total <= 0) {
|
|
31
|
+
drawMessage(ctx, width, height, 'No positive values for pie chart');
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let startAngle = -Math.PI / 2;
|
|
36
|
+
positiveValues.forEach((val, idx) => {
|
|
37
|
+
if (val <= 0) return;
|
|
38
|
+
|
|
39
|
+
const sliceAngle = (val / total) * (Math.PI * 2);
|
|
40
|
+
const endAngle = startAngle + sliceAngle;
|
|
41
|
+
const color = getColor(dataset.backgroundColor, idx);
|
|
42
|
+
|
|
43
|
+
ctx.fillStyle = color;
|
|
44
|
+
ctx.beginPath();
|
|
45
|
+
ctx.moveTo(cx, cy);
|
|
46
|
+
ctx.arc(cx, cy, radius, startAngle, endAngle);
|
|
47
|
+
ctx.closePath();
|
|
48
|
+
ctx.fill();
|
|
49
|
+
|
|
50
|
+
ctx.strokeStyle = 'rgba(24, 24, 27, 0.8)';
|
|
51
|
+
ctx.lineWidth = 2;
|
|
52
|
+
ctx.stroke();
|
|
53
|
+
|
|
54
|
+
startAngle = endAngle;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (this.isDoughnut) {
|
|
58
|
+
ctx.save();
|
|
59
|
+
ctx.globalCompositeOperation = 'destination-out';
|
|
60
|
+
ctx.beginPath();
|
|
61
|
+
ctx.arc(cx, cy, radius * 0.58, 0, Math.PI * 2);
|
|
62
|
+
ctx.fill();
|
|
63
|
+
ctx.restore();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { ChartData, ChartOptions } from './types';
|
|
2
|
+
|
|
3
|
+
export interface ScaleInfo {
|
|
4
|
+
minVal: number;
|
|
5
|
+
maxVal: number;
|
|
6
|
+
range: number;
|
|
7
|
+
baselineY: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ChartPadding {
|
|
11
|
+
top: number;
|
|
12
|
+
right: number;
|
|
13
|
+
bottom: number;
|
|
14
|
+
left: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_CHART_PADDING: ChartPadding = {
|
|
18
|
+
top: 20,
|
|
19
|
+
right: 20,
|
|
20
|
+
bottom: 35,
|
|
21
|
+
left: 45,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Calculates Y axis scale supporting both positive and negative values.
|
|
26
|
+
*/
|
|
27
|
+
export function calculateScale(
|
|
28
|
+
datasets: ChartData['datasets'],
|
|
29
|
+
options: ChartOptions,
|
|
30
|
+
chartH: number,
|
|
31
|
+
paddingTop: number = DEFAULT_CHART_PADDING.top
|
|
32
|
+
): ScaleInfo {
|
|
33
|
+
let dataMin = Infinity;
|
|
34
|
+
let dataMax = -Infinity;
|
|
35
|
+
let hasValidData = false;
|
|
36
|
+
|
|
37
|
+
for (const ds of datasets) {
|
|
38
|
+
if (ds.hidden) continue;
|
|
39
|
+
for (const val of ds.data) {
|
|
40
|
+
if (typeof val === 'number' && Number.isFinite(val)) {
|
|
41
|
+
hasValidData = true;
|
|
42
|
+
if (val < dataMin) dataMin = val;
|
|
43
|
+
if (val > dataMax) dataMax = val;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!hasValidData) {
|
|
49
|
+
dataMin = 0;
|
|
50
|
+
dataMax = 10;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const userMinValid = typeof options.min === 'number' && Number.isFinite(options.min);
|
|
54
|
+
const userMaxValid = typeof options.max === 'number' && Number.isFinite(options.max);
|
|
55
|
+
|
|
56
|
+
let minVal: number;
|
|
57
|
+
let maxVal: number;
|
|
58
|
+
|
|
59
|
+
if (userMinValid && userMaxValid) {
|
|
60
|
+
if (options.min! < options.max!) {
|
|
61
|
+
minVal = options.min!;
|
|
62
|
+
maxVal = options.max!;
|
|
63
|
+
} else {
|
|
64
|
+
console.warn(
|
|
65
|
+
`[NuiChart] Invalid scale configuration: min (${options.min}) >= max (${options.max}). Falling back to auto range.`
|
|
66
|
+
);
|
|
67
|
+
minVal = dataMin < 0 ? Math.floor(dataMin * 1.15) : 0;
|
|
68
|
+
maxVal = dataMax > 0 ? Math.ceil(dataMax * 1.15) : 10;
|
|
69
|
+
}
|
|
70
|
+
} else if (userMinValid) {
|
|
71
|
+
minVal = options.min!;
|
|
72
|
+
maxVal = Math.max(minVal + 1, dataMax > 0 ? Math.ceil(dataMax * 1.15) : 10);
|
|
73
|
+
} else if (userMaxValid) {
|
|
74
|
+
maxVal = options.max!;
|
|
75
|
+
minVal = Math.min(maxVal - 1, dataMin < 0 ? Math.floor(dataMin * 1.15) : 0);
|
|
76
|
+
} else {
|
|
77
|
+
// Auto scale covering zero
|
|
78
|
+
minVal = dataMin < 0 ? Math.floor(dataMin * 1.15) : 0;
|
|
79
|
+
maxVal = dataMax > 0 ? Math.ceil(dataMax * 1.15) : 10;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (minVal >= maxVal) {
|
|
83
|
+
maxVal = minVal + 10;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const range = maxVal - minVal;
|
|
87
|
+
const rawZeroY = paddingTop + chartH - ((0 - minVal) / range) * chartH;
|
|
88
|
+
const baselineY = Math.max(paddingTop, Math.min(paddingTop + chartH, rawZeroY));
|
|
89
|
+
|
|
90
|
+
return { minVal, maxVal, range, baselineY };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Draws horizontal grid lines, Y axis labels, and highlighted zero baseline.
|
|
95
|
+
*/
|
|
96
|
+
export function drawGridAndYLabels(
|
|
97
|
+
ctx: CanvasRenderingContext2D,
|
|
98
|
+
padding: ChartPadding,
|
|
99
|
+
chartW: number,
|
|
100
|
+
chartH: number,
|
|
101
|
+
scale: ScaleInfo,
|
|
102
|
+
options: ChartOptions
|
|
103
|
+
): void {
|
|
104
|
+
const showGrid = options.showGrid !== false;
|
|
105
|
+
const showLabels = options.showLabels !== false;
|
|
106
|
+
const gridSteps = 4;
|
|
107
|
+
|
|
108
|
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
|
|
109
|
+
ctx.fillStyle = '#a1a1aa';
|
|
110
|
+
ctx.font = '11px sans-serif';
|
|
111
|
+
ctx.textAlign = 'right';
|
|
112
|
+
ctx.textBaseline = 'middle';
|
|
113
|
+
ctx.lineWidth = 1;
|
|
114
|
+
|
|
115
|
+
for (let i = 0; i <= gridSteps; i++) {
|
|
116
|
+
const yVal = scale.minVal + (scale.range / gridSteps) * i;
|
|
117
|
+
const y = padding.top + chartH - ((yVal - scale.minVal) / scale.range) * chartH;
|
|
118
|
+
|
|
119
|
+
if (showGrid) {
|
|
120
|
+
ctx.beginPath();
|
|
121
|
+
ctx.moveTo(padding.left, y);
|
|
122
|
+
ctx.lineTo(padding.left + chartW, y);
|
|
123
|
+
ctx.stroke();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (showLabels) {
|
|
127
|
+
ctx.fillText(Math.round(yVal).toString(), padding.left - 8, y);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Highlight zero baseline if range covers both negative and positive values
|
|
132
|
+
if (scale.minVal < 0 && scale.maxVal > 0) {
|
|
133
|
+
ctx.strokeStyle = 'rgba(255, 255, 255, 0.25)';
|
|
134
|
+
ctx.beginPath();
|
|
135
|
+
ctx.moveTo(padding.left, scale.baselineY);
|
|
136
|
+
ctx.lineTo(padding.left + chartW, scale.baselineY);
|
|
137
|
+
ctx.stroke();
|
|
138
|
+
}
|
|
139
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supported Canvas chart types.
|
|
3
|
+
*/
|
|
4
|
+
export type ChartType = 'bar' | 'line' | 'pie' | 'doughnut';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Single dataset to render on a chart.
|
|
8
|
+
*/
|
|
9
|
+
export interface ChartDataset {
|
|
10
|
+
/** Dataset label displayed in the legend and accessibility attributes. */
|
|
11
|
+
label: string;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Numerical values for the dataset.
|
|
15
|
+
* `null` or `undefined` indicates a missing data point.
|
|
16
|
+
*/
|
|
17
|
+
data: (number | null | undefined)[];
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Background color for bars, slices, or area fill.
|
|
21
|
+
* Accepts any valid CSS color string or an array of colors.
|
|
22
|
+
*/
|
|
23
|
+
backgroundColor?: string | string[];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Border color for lines or bars.
|
|
27
|
+
* Accepts any valid CSS color string or an array of colors.
|
|
28
|
+
*/
|
|
29
|
+
borderColor?: string | string[];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Border / line width in pixels.
|
|
33
|
+
* @defaultValue 2
|
|
34
|
+
*/
|
|
35
|
+
borderWidth?: number;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether the dataset is initially hidden.
|
|
39
|
+
* @defaultValue false
|
|
40
|
+
*/
|
|
41
|
+
hidden?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Chart input data structure.
|
|
46
|
+
*/
|
|
47
|
+
export interface ChartData {
|
|
48
|
+
/** Category labels for the X axis or slice labels for pie/doughnut. */
|
|
49
|
+
labels: string[];
|
|
50
|
+
|
|
51
|
+
/** Array of datasets to render. */
|
|
52
|
+
datasets: ChartDataset[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Chart rendering, grid, scale, and accessibility options.
|
|
57
|
+
*/
|
|
58
|
+
export interface ChartOptions {
|
|
59
|
+
/**
|
|
60
|
+
* Legend position configuration.
|
|
61
|
+
* @defaultValue { position: 'bottom' }
|
|
62
|
+
*/
|
|
63
|
+
legend?: {
|
|
64
|
+
position?: 'top' | 'bottom' | 'none';
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Backward compatible plugin configuration.
|
|
69
|
+
*/
|
|
70
|
+
plugins?: {
|
|
71
|
+
legend?: {
|
|
72
|
+
position?: 'top' | 'bottom' | 'none';
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether to render horizontal grid lines.
|
|
78
|
+
* @defaultValue true
|
|
79
|
+
*/
|
|
80
|
+
showGrid?: boolean;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether to show axis labels.
|
|
84
|
+
* @defaultValue true
|
|
85
|
+
*/
|
|
86
|
+
showLabels?: boolean;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Forced value for the Y axis scale.
|
|
90
|
+
*/
|
|
91
|
+
min?: number;
|
|
92
|
+
max?: number;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Accessible description for screen readers.
|
|
96
|
+
*/
|
|
97
|
+
ariaLabel?: string;
|
|
98
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { ChartData, ChartDataset } from './types';
|
|
2
|
+
|
|
3
|
+
export interface ChartValidationResult {
|
|
4
|
+
valid: boolean;
|
|
5
|
+
data?: ChartData;
|
|
6
|
+
error?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Validates chart data structure (especially useful for NUI game events).
|
|
11
|
+
*/
|
|
12
|
+
export function validateChartData(raw: unknown): ChartValidationResult {
|
|
13
|
+
if (!raw || typeof raw !== 'object') {
|
|
14
|
+
return { valid: false, error: 'Chart data must be an object' };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const obj = raw as Record<string, unknown>;
|
|
18
|
+
if (!Array.isArray(obj.datasets)) {
|
|
19
|
+
return { valid: false, error: 'Missing datasets array' };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const labels = Array.isArray(obj.labels)
|
|
23
|
+
? obj.labels.map((l) => (l !== null && l !== undefined ? String(l) : ''))
|
|
24
|
+
: [];
|
|
25
|
+
|
|
26
|
+
const datasets: ChartDataset[] = [];
|
|
27
|
+
for (let i = 0; i < obj.datasets.length; i++) {
|
|
28
|
+
const ds = obj.datasets[i];
|
|
29
|
+
if (!ds || typeof ds !== 'object') {
|
|
30
|
+
return { valid: false, error: `Dataset at index ${i} is not a valid object` };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const dsObj = ds as Record<string, unknown>;
|
|
34
|
+
const label = typeof dsObj.label === 'string' ? dsObj.label : `Dataset ${i + 1}`;
|
|
35
|
+
|
|
36
|
+
if (!Array.isArray(dsObj.data)) {
|
|
37
|
+
return { valid: false, error: `Dataset '${label}' is missing data array` };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const data: (number | null | undefined)[] = [];
|
|
41
|
+
for (let j = 0; j < dsObj.data.length; j++) {
|
|
42
|
+
const v = dsObj.data[j];
|
|
43
|
+
if (v === null || v === undefined) {
|
|
44
|
+
data.push(null);
|
|
45
|
+
} else if (typeof v === 'number' && Number.isFinite(v)) {
|
|
46
|
+
data.push(v);
|
|
47
|
+
} else {
|
|
48
|
+
return {
|
|
49
|
+
valid: false,
|
|
50
|
+
error: `Dataset '${label}' contains invalid value at index ${j}: ${String(v)} (expected finite number or null)`,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let borderWidth: number | undefined = undefined;
|
|
56
|
+
if (dsObj.borderWidth !== undefined) {
|
|
57
|
+
if (typeof dsObj.borderWidth !== 'number' || !Number.isFinite(dsObj.borderWidth) || dsObj.borderWidth < 0) {
|
|
58
|
+
return { valid: false, error: `Dataset '${label}' has invalid borderWidth (must be number >= 0)` };
|
|
59
|
+
}
|
|
60
|
+
borderWidth = dsObj.borderWidth;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let backgroundColor: string | string[] | undefined = undefined;
|
|
64
|
+
if (typeof dsObj.backgroundColor === 'string') {
|
|
65
|
+
backgroundColor = dsObj.backgroundColor;
|
|
66
|
+
} else if (Array.isArray(dsObj.backgroundColor)) {
|
|
67
|
+
if (dsObj.backgroundColor.some((c) => typeof c !== 'string' || !c.trim())) {
|
|
68
|
+
return { valid: false, error: `Dataset '${label}' has invalid colors in backgroundColor` };
|
|
69
|
+
}
|
|
70
|
+
backgroundColor = dsObj.backgroundColor as string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let borderColor: string | string[] | undefined = undefined;
|
|
74
|
+
if (typeof dsObj.borderColor === 'string') {
|
|
75
|
+
borderColor = dsObj.borderColor;
|
|
76
|
+
} else if (Array.isArray(dsObj.borderColor)) {
|
|
77
|
+
if (dsObj.borderColor.some((c) => typeof c !== 'string' || !c.trim())) {
|
|
78
|
+
return { valid: false, error: `Dataset '${label}' has invalid colors in borderColor` };
|
|
79
|
+
}
|
|
80
|
+
borderColor = dsObj.borderColor as string[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
datasets.push({
|
|
84
|
+
label,
|
|
85
|
+
data,
|
|
86
|
+
backgroundColor,
|
|
87
|
+
borderColor,
|
|
88
|
+
borderWidth,
|
|
89
|
+
hidden: typeof dsObj.hidden === 'boolean' ? dsObj.hidden : false,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (datasets.length === 0) {
|
|
94
|
+
return { valid: false, error: 'datasets array cannot be empty' };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { valid: true, data: { labels, datasets } };
|
|
98
|
+
}
|