Skip to content

Instantly share code, notes, and snippets.

@diramazioni
Last active November 16, 2025 12:22
Show Gist options
  • Select an option

  • Save diramazioni/dbc70716ba3313e3d857fb4725846ffd to your computer and use it in GitHub Desktop.

Select an option

Save diramazioni/dbc70716ba3313e3d857fb4725846ffd to your computer and use it in GitHub Desktop.
layerchart page
<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>
@diramazioni
Copy link
Author

rainChartData derives all the new values as soon as data arrives AFAIK

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment