Last active
November 16, 2025 12:22
-
-
Save diramazioni/dbc70716ba3313e3d857fb4725846ffd to your computer and use it in GitHub Desktop.
layerchart page
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <script lang="ts"> | |
| import { | |
| LineChart, | |
| AreaChart, | |
| BarChart, | |
| PieChart, | |
| ScatterChart, | |
| Tooltip, | |
| Labels, | |
| Threshold, | |
| Area, | |
| Spline, | |
| Chart, | |
| Layer, | |
| Axis, | |
| Highlight, | |
| Group, | |
| Bars, | |
| type ChartContextValue | |
| } from 'layerchart'; | |
| import { extent, max, mean, sum, nice } from 'd3-array'; | |
| import { scaleBand, scaleLinear, scaleOrdinal, scaleTime } from 'd3-scale'; | |
| import { timeDay, timeHour } from 'd3-time'; | |
| import { browser } from '$app/environment'; | |
| import { cn } from '$lib/utils'; | |
| import { format } from 'date-fns'; | |
| import * as Accordion from '$lib/components/ui/accordion/index.js'; | |
| import { Switch } from '$lib/components/ui/switch/index.js'; | |
| import DeviceSelector from '$lib/components/DeviceSelector.svelte'; | |
| import DateRangePicker from '$lib/components/DateRangePicker.svelte'; | |
| import type { PageData } from './$types'; | |
| import { Button } from '$lib/components/ui/button'; | |
| import { ChartNoAxesCombined, TableProperties, Table2, ExternalLink } from '@lucide/svelte'; | |
| import type { | |
| DeviceSelectItem, | |
| ChartGroupDefinition, | |
| ApiChartResponse, | |
| SeriesDefinition, | |
| ChartSplitDataPoint, | |
| PlotDataPoint, | |
| ChartGroupData, | |
| LayerchartTooltipContext, | |
| TooltipContextData | |
| } from '$lib/types.d.ts'; | |
| import { fetch_range, fetch_chart_data } from '$lib/shared'; | |
| import { getTextColor, sendToastGreen } from '$lib/uiUtils'; | |
| import { getIconComponent } from '$lib/iconStore'; | |
| import { onMount, onDestroy } from 'svelte'; | |
| import { getWebSocketContext } from '$lib/contexts/websocketContext'; | |
| function getDeviceTypeNameById( | |
| deviceTypeId: string | undefined, | |
| deviceTypes: { id: number; name: string }[] | |
| ): string | undefined { | |
| if (!deviceTypeId) return undefined; | |
| const deviceTypeDefinition = deviceTypes.find((dt) => dt.id === Number(deviceTypeId)); | |
| return deviceTypeDefinition?.name; | |
| } | |
| let { data }: { data: PageData } = $props(); | |
| let cleanupRegistration: (() => void) | null = null; | |
| let chartGroups = $state<ChartGroupDefinition[]>([]); | |
| let seriesDataForGroups = $state<Record<string, ChartGroupData>>({}); | |
| let isLoading = $state(true); | |
| let errorMessage = $state<string | null>(null); | |
| let openAccordionItem = $state<string[]>([]); | |
| let isLive = $state(true); | |
| let availableDevicesForType = $state<DeviceSelectItem[]>([]); | |
| let selectedDeviceObjects = $derived( | |
| availableDevicesForType.filter((device) => selections.selectedDeviceIds?.includes(device.value)) | |
| ); | |
| let deviceNames = $derived(selectedDeviceObjects.map((d) => d.name)); | |
| let selections = $state({ | |
| _selectedDeviceTypeId: data.preselectedDeviceTypeId?.toString(), | |
| _selectedDeviceIds: data.preselectedDeviceIds ?? [], | |
| _rangeState: { | |
| start: data.preselectedRangeStart ? new Date(data.preselectedRangeStart) : undefined, | |
| end: data.preselectedRangeEnd ? new Date(data.preselectedRangeEnd) : undefined | |
| }, | |
| get selectedDeviceTypeId() { | |
| return this._selectedDeviceTypeId; | |
| }, | |
| get selectedDeviceIds() { | |
| return this._selectedDeviceIds; | |
| }, | |
| get rangeState() { | |
| return this._rangeState; | |
| }, | |
| set selectedDeviceTypeId(v: string | undefined) { | |
| this._selectedDeviceTypeId = v; | |
| this._selectedDeviceIds = []; | |
| }, | |
| set selectedDeviceIds(v: string[]) { | |
| this._selectedDeviceIds = v; | |
| checkAndFetch_internal(this); | |
| cleanupRegistration = wsContext.registerUpdateCallback(deviceNames, updateHandler); | |
| }, | |
| set rangeState(v: { start: Date | undefined; end: Date | undefined }) { | |
| this._rangeState = { | |
| start: v.start instanceof Date && !isNaN(v.start.getTime()) ? v.start : undefined, | |
| end: v.end instanceof Date && !isNaN(v.end.getTime()) ? v.end : undefined | |
| }; | |
| checkAndFetch_internal(this); | |
| } | |
| }); | |
| let context = $state<ChartContextValue<ChartSplitDataPoint> | undefined>(); | |
| const chartComponents: Record<string, any> = { | |
| LineChart: LineChart, | |
| AreaChart: AreaChart, | |
| BarChart: BarChart, | |
| PieChart: PieChart, | |
| ScatterChart: ScatterChart | |
| }; | |
| type ChartTypeKey = keyof typeof chartComponents; | |
| async function checkAndFetch_internal(context: typeof selections) { | |
| let startDateString = | |
| context._rangeState.start instanceof Date && !isNaN(context._rangeState.start.getTime()) | |
| ? context._rangeState.start.toISOString() | |
| : undefined; | |
| let endDateString = | |
| context._rangeState.end instanceof Date && !isNaN(context._rangeState.end.getTime()) | |
| ? context._rangeState.end.toISOString() | |
| : undefined; | |
| const conditionsMet = | |
| context._selectedDeviceTypeId && | |
| context._selectedDeviceIds.length > 0 && | |
| startDateString && | |
| endDateString; | |
| if (conditionsMet) { | |
| isLoading = true; | |
| fetch_chart_data( | |
| fetch, | |
| context._selectedDeviceTypeId!, | |
| context._selectedDeviceIds, | |
| availableDevicesForType, | |
| data.deviceTypes, | |
| data.sensorDefinitions, | |
| startDateString!, | |
| endDateString! | |
| ) | |
| .then((apiResponse: ApiChartResponse) => { | |
| chartGroups = apiResponse.chartGroups; | |
| seriesDataForGroups = apiResponse.seriesDataForGroups; | |
| for (const groupName in seriesDataForGroups) { | |
| if (seriesDataForGroups.hasOwnProperty(groupName)) { | |
| const chartGroupData = seriesDataForGroups[groupName]; | |
| if (chartGroupData && chartGroupData.seriesDefinition) { | |
| chartGroupData.seriesDefinition.forEach((series) => { | |
| if (series.data) { | |
| series.data.forEach((point) => { | |
| point.date = point.date instanceof Date ? point.date : new Date(point.date); | |
| }); | |
| } | |
| }); | |
| } | |
| } | |
| } | |
| isLoading = false; | |
| errorMessage = null; | |
| }) | |
| .catch((e) => { | |
| errorMessage = e.message; | |
| isLoading = false; | |
| chartGroups = []; | |
| seriesDataForGroups = {}; | |
| }); | |
| } else { | |
| chartGroups = []; | |
| seriesDataForGroups = {}; | |
| isLoading = false; | |
| errorMessage = null; | |
| } | |
| } | |
| async function updateLiveRange() { | |
| if (selections.selectedDeviceTypeId && deviceNames.length > 0) { | |
| try { | |
| const range = await fetch_range(fetch, selections.selectedDeviceTypeId, deviceNames[0]); | |
| const endDateString = range[2]; | |
| const startDateString = range[1]; | |
| if (endDateString && startDateString) { | |
| const end = new Date(endDateString); | |
| const start = new Date(end.getTime() - 60 * 60 * 1000); | |
| selections.rangeState = { start: start, end: end }; | |
| } else { | |
| selections.rangeState = { start: undefined, end: undefined }; | |
| } | |
| } catch (error) { | |
| selections.rangeState = { start: undefined, end: undefined }; | |
| } | |
| } else { | |
| selections.rangeState = { start: undefined, end: undefined }; | |
| } | |
| } | |
| async function updateHandler(edata: any) { | |
| if (isLive) { | |
| await updateLiveRange(); | |
| await checkAndFetch_internal(selections); | |
| } | |
| sendToastGreen(edata.device); | |
| } | |
| const wsContext = getWebSocketContext(); | |
| onMount(() => { | |
| if (deviceNames.length > 0 && wsContext) { | |
| cleanupRegistration = wsContext.registerUpdateCallback(deviceNames, updateHandler); | |
| } | |
| return () => { | |
| if (cleanupRegistration) { | |
| cleanupRegistration(); | |
| } | |
| if (cleanupRegistration) { | |
| cleanupRegistration(); | |
| cleanupRegistration = null; | |
| } | |
| }; | |
| }); | |
| let chartAnnotations = $derived( | |
| (() => { | |
| const annotations: Record<string, any[]> = {}; | |
| if ( | |
| !data.sensorTriggers || | |
| data.sensorTriggers.length === 0 || | |
| !chartGroups || | |
| chartGroups.length === 0 || | |
| !selections.selectedDeviceIds || | |
| selections.selectedDeviceIds.length === 0 || | |
| !data.sensorDefinitions | |
| ) { | |
| return annotations; | |
| } | |
| const selectedDeviceIdsNum = selections.selectedDeviceIds.map((id) => Number(id)); | |
| const sensorNameToId = new Map(data.sensorDefinitions.map((s) => [s.name, s.id])); | |
| for (const chartGroup of chartGroups) { | |
| const relevantSensorIds = chartGroup.sensorNames | |
| .map((name) => sensorNameToId.get(name)) | |
| .filter((id) => id !== undefined) as number[]; | |
| const groupTriggers = data.sensorTriggers.filter( | |
| (trigger) => | |
| relevantSensorIds.includes(trigger.sensorId) && | |
| selectedDeviceIdsNum.includes(trigger.deviceId) | |
| ); | |
| annotations[chartGroup.name] = groupTriggers.map((trigger) => ({ | |
| type: 'line', | |
| y: trigger.triggerValue, | |
| label: trigger.label || trigger.triggerValue.toString(), | |
| labelXOffset: 10, | |
| labelYOffset: 10, | |
| labelPlacement: 'top-left', | |
| props: { | |
| label: { class: `${trigger.color} font-bold` }, | |
| line: { class: `${trigger.color || 'stroke-gray-500'}` } | |
| } | |
| })); | |
| } | |
| return annotations; | |
| })() | |
| ); | |
| let rainChartData = $derived( | |
| (() => { | |
| const rainData: Record< | |
| string, | |
| { | |
| combinedData: Array<{ date: Date; rain: number; cumulative: number }>; | |
| maxRain: number; | |
| maxCumulative: number; | |
| rainDomain: [number, number]; | |
| cumulativeDomain: [number, number]; | |
| baselineScale: any; | |
| rainSeries: any; | |
| cumulativeSeries: any; | |
| } | |
| > = {}; | |
| for (const chartGroup of chartGroups) { | |
| if (chartGroup.name === 'Rain' || chartGroup.name === 'PG') { | |
| const currentSplitSeries = seriesDataForGroups[chartGroup.name]; | |
| if (currentSplitSeries && currentSplitSeries.seriesDefinition.length > 1) { | |
| const rainSeries = currentSplitSeries.seriesDefinition.find((s) => | |
| s.key.startsWith('rain_daily_') | |
| ); | |
| const cumulativeSeries = currentSplitSeries.seriesDefinition.find((s) => | |
| s.key.endsWith('_cumulative_sum') | |
| ); | |
| if (rainSeries && cumulativeSeries) { | |
| const combinedData = rainSeries.data.map((d, i) => ({ | |
| date: d.date, | |
| rain: d.value, | |
| cumulative: cumulativeSeries.data[i]?.value ?? 0 | |
| })); | |
| const maxRain = max(combinedData, (d) => d.rain) || 0; | |
| const maxCumulative = max(combinedData, (d) => d.cumulative) || 0; | |
| const rainDomain = maxRain > 0 ? nice(0, maxRain, 5) : [0, 1]; | |
| const cumulativeDomain = maxCumulative > 0 ? [0, maxCumulative] : [0, 1]; | |
| const baselineScale = scaleLinear(rainDomain, cumulativeDomain); | |
| rainData[chartGroup.name] = { | |
| combinedData, | |
| maxRain, | |
| maxCumulative, | |
| rainDomain, | |
| cumulativeDomain, | |
| baselineScale, | |
| rainSeries, | |
| cumulativeSeries | |
| }; | |
| } | |
| } | |
| } | |
| } | |
| return rainData; | |
| })() | |
| ); | |
| </script> | |
| <div class="p-4 w-full"> | |
| <div class="flex flex-col sm:flex-row sm:justify-between sm:items-end mb-4 w-full gap-4"> | |
| <h1 class="text-2xl font-bold text-gray-800 dark:text-gray-100"> | |
| <ChartNoAxesCombined class="mr-2 inline-block" /> | |
| Visualizzazione Grafici Dispositivi | |
| </h1> | |
| {#if selections.selectedDeviceIds.length > 0} | |
| {@const deviceNamesString = selectedDeviceObjects.map((d) => d.name).join(',') || ''} | |
| <div class="flex space-x-2"> | |
| <a href={`/grid?devices=${deviceNamesString}`}> | |
| <Button variant="outline" size="sm" class="bg-sky-500 text-white"> | |
| <TableProperties class="mr-2 h-4 w-4" /> | |
| Vai ai dati di dettaglio | |
| </Button> | |
| </a> | |
| <a href={`/report?devices=${deviceNamesString}`}> | |
| <Button variant="outline" size="sm" class="bg-blue-500 text-white"> | |
| <Table2 class="mr-2 h-4 w-4" /> | |
| Vai al Report | |
| </Button> | |
| </a> | |
| </div> | |
| {/if} | |
| </div> | |
| <div class="flex flex-wrap gap-6"> | |
| <div class="w-full sm:w-1/2"> | |
| <h3 class="text-lg font-semibold mb-2">Seleziona Dispositivi</h3> | |
| <DeviceSelector | |
| deviceTypes={data.deviceTypes.map((dt: { id: number; name: string }) => ({ | |
| value: dt.id.toString(), | |
| label: dt.name | |
| }))} | |
| initialDeviceIds={data.preselectedDeviceIds} | |
| bind:typeId={selections.selectedDeviceTypeId} | |
| bind:selectedDeviceIds={selections.selectedDeviceIds} | |
| bind:availableDevices={availableDevicesForType} | |
| /> | |
| </div> | |
| <div class="w-full sm:w-1/2"> | |
| <h3 class="text-lg font-semibold mb-2">Seleziona Intervallo Date</h3> | |
| <div class="flex flex-col sm:flex-row gap-4 items-center"> | |
| <label class="flex items-center space-x-2"> | |
| <span class="text-blue-600 dark:text-blue-200">Live</span> | |
| <Switch id="split-mode" bind:checked={isLive} class="text-blue-600" /> | |
| </label> | |
| <DateRangePicker bind:value={selections.rangeState} /> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="w-full"> | |
| {#if isLoading} | |
| <p class="text-center text-2xl text-lime-500">Caricamento dati...</p> | |
| {:else if errorMessage} | |
| <p class="text-red-500">Errore: {errorMessage}</p> | |
| {:else if chartGroups.length === 0} | |
| <p class="text-center text-2xl text-slate-500"> | |
| Nessun dato disponibile per i dispositivi selezionati nell'intervallo di date specificato. | |
| </p> | |
| {:else} | |
| <Accordion.Root class="w-full px-4" type="multiple" bind:value={openAccordionItem}> | |
| {#each chartGroups as chartGroup (chartGroup.id)} | |
| {@const Component = chartComponents[chartGroup.chartType as ChartTypeKey]} | |
| {@const currentSplitSeries = seriesDataForGroups[chartGroup.name]} | |
| {@const GroupIcon = getIconComponent(chartGroup.icon)} | |
| {#if currentSplitSeries && currentSplitSeries.seriesDefinition.length > 0} | |
| <Accordion.Item value={chartGroup.name}> | |
| <Accordion.Trigger | |
| class={cn('rounded-md px-4 py-2 border cursor-pointer', chartGroup.color)} | |
| style="color: {getTextColor(chartGroup.color)};" | |
| > | |
| <div class="flex justify-between items-center w-full"> | |
| <div class="flex items-center gap-3"> | |
| {#if GroupIcon} | |
| <GroupIcon size={18} class="opacity-90" /> | |
| {/if} | |
| <span class="text-bold px-2 py-0.5 rounded-full bg-background/50 font-medium" | |
| >{chartGroup.title}</span | |
| > | |
| </div> | |
| </div> | |
| </Accordion.Trigger> | |
| <Accordion.Content> | |
| <div style="height: 500px;" class="container p-8 w-full"> | |
| {#if rainChartData[chartGroup.name]} | |
| {@const chartData = rainChartData[chartGroup.name]} | |
| <BarChart | |
| data={chartData.combinedData} | |
| x="date" | |
| xScale={scaleBand()} | |
| series={[ | |
| { | |
| key: 'rain', | |
| label: chartData.rainSeries.label, | |
| color: chartData.rainSeries.color, | |
| value: (d) => chartData.baselineScale(d.rain) | |
| }, | |
| { | |
| key: 'cumulative', | |
| label: chartData.cumulativeSeries.label, | |
| color: chartData.cumulativeSeries.color | |
| } | |
| ]} | |
| padding={{ top: 24, bottom: 48, left: 48, right: 48 }} | |
| legend | |
| > | |
| {#snippet marks({ context, visibleSeries })} | |
| {@const start = context.xDomain[0]} | |
| {@const bandwidth = context.xInterval | |
| ? (context.xScale(context.xInterval.offset(start)) - | |
| context.xScale(start)) / | |
| 2 | |
| : 0} | |
| {#each visibleSeries as s, i} | |
| {#if s.key === 'cumulative'} | |
| <Group x={bandwidth}> | |
| <Spline y={s.key} color={s.color} /> | |
| </Group> | |
| {:else} | |
| <Bars y={s.value} insets={{ x: bandwidth * 0.2 }} color={s.color} /> | |
| {/if} | |
| {/each} | |
| {/snippet} | |
| {#snippet axis({ context, visibleSeries })} | |
| {@const visibleSeriesKeys = visibleSeries.map((s) => s.key)} | |
| <Axis | |
| placement="bottom" | |
| rule | |
| grid | |
| tickMultiline | |
| ticks={(scale) => | |
| scaleTime(scale.domain(), scale.range()).ticks(scale.range()[1] / 80)} | |
| /> | |
| <!-- | |
| tickSpacing={80} | |
| --> | |
| {#if visibleSeriesKeys.includes('rain')} | |
| <Axis | |
| placement="left" | |
| label={`${chartData.rainSeries.label} (${chartData.rainSeries.unit})`} | |
| ticks={chartData.baselineScale.ticks()} | |
| scale={scaleLinear(chartData.baselineScale.domain(), [context.height, 0])} | |
| rule | |
| /> | |
| {/if} | |
| {#if visibleSeriesKeys.includes('cumulative')} | |
| <Axis | |
| placement="right" | |
| label={`${chartData.cumulativeSeries.label} (${chartData.cumulativeSeries.unit})`} | |
| labelPlacement="start" | |
| format="metric" | |
| rule | |
| /> | |
| {/if} | |
| {/snippet} | |
| {#snippet tooltip({ context, visibleSeries })} | |
| <Tooltip.Root {context}> | |
| {#snippet children({ data: hovered })} | |
| <Tooltip.Header>{hovered.date.toLocaleString()}</Tooltip.Header> | |
| <Tooltip.List> | |
| {#each visibleSeries as s} | |
| {@const format = (v: number) => v + ' mm'} | |
| <Tooltip.Item | |
| label={s.label} | |
| color={s.color} | |
| value={hovered[s.key as keyof typeof hovered]} | |
| {format} | |
| /> | |
| {/each} | |
| </Tooltip.List> | |
| {/snippet} | |
| </Tooltip.Root> | |
| {/snippet} | |
| </BarChart> | |
| {:else} | |
| <Component | |
| bind:context | |
| series={currentSplitSeries.seriesDefinition} | |
| x="date" | |
| y="value" | |
| yDomain={chartGroup.name === 'LV' ? [0, 400] : undefined} | |
| legend | |
| brush | |
| props={{ | |
| xAxis: { | |
| format: (d: Date) => format(d, 'dd/MM'), | |
| tickCount: 8, | |
| tickSize: 6 | |
| }, | |
| tooltip: { context: { mode: 'quadtree' } } | |
| }} | |
| annotations={chartAnnotations[chartGroup.name] || []} | |
| > | |
| {#snippet tooltip({ | |
| context, | |
| series | |
| }: { | |
| context: ChartContextValue<ChartSplitDataPoint>; | |
| series: SeriesDefinition[]; | |
| })} | |
| {@const hoveredDataPoint = context.tooltip.data as | |
| | TooltipContextData | |
| | undefined} | |
| {@const activeSeriesDef = hoveredDataPoint | |
| ? series.find((s) => s.key === hoveredDataPoint.seriesKey) | |
| : undefined} | |
| <Tooltip.Root> | |
| {#snippet children({ data: localHoveredDataPoint })} | |
| <Tooltip.Header> | |
| {localHoveredDataPoint?.date instanceof Date | |
| ? localHoveredDataPoint.date.toLocaleString('it-IT', { | |
| dateStyle: 'short', | |
| timeStyle: 'medium' | |
| }) | |
| : 'Invalid Date'} | |
| </Tooltip.Header> | |
| <Tooltip.List> | |
| {#if activeSeriesDef && localHoveredDataPoint} | |
| <Tooltip.Item | |
| label={activeSeriesDef.label} | |
| value={`${localHoveredDataPoint.value === null || localHoveredDataPoint.value === undefined ? 'N/A' : localHoveredDataPoint.value.toFixed(activeSeriesDef.roundDigits ?? 0)} ${activeSeriesDef.unit ?? ''}`} | |
| color={activeSeriesDef.color} | |
| class="text-bold inline-flex space-x-2" | |
| /> | |
| {/if} | |
| </Tooltip.List> | |
| {/snippet} | |
| </Tooltip.Root> | |
| {/snippet} | |
| </Component> | |
| {/if} | |
| </div> | |
| </Accordion.Content> | |
| </Accordion.Item> | |
| {/if} | |
| {/each} | |
| </Accordion.Root> | |
| {/if} | |
| </div> | |
| <style> | |
| .container { | |
| height: 400px; | |
| } | |
| </style> |
Author
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
rainChartData derives all the new values as soon as data arrives AFAIK