diff --git a/.npmrc b/.npmrc
index 5c14126a..63fe4a52 100644
--- a/.npmrc
+++ b/.npmrc
@@ -1,5 +1,2 @@
engine-strict=true
node-linker=hoisted
-@cropwatchdevelopment:registry=https://npm.pkg.github.com
-//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
-always-auth=true
diff --git a/messages/en.json b/messages/en.json
index c0c2f6de..351d2873 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -911,6 +911,10 @@
"traffic_calendar_auth_required": "Calendar data requires an authenticated session.",
"traffic_calendar_fetch_subtitle": "{monthLabel} calendar fetch",
"traffic_calendar_total": "Traffic Total: {count}",
+ "traffic_chart_hourly_subtitle": "Today, stacked by detection class",
+ "traffic_chart_hourly_title": "Hourly Traffic Volume",
+ "traffic_class_breakdown": "Class Breakdown",
+ "traffic_class_breakdown_subtitle": "Share of today's total",
"traffic_current_hour_today": "Current hour today",
"traffic_current_hour_total": "Current Hour Total",
"traffic_daily_traffic_weather": "Daily Traffic & Weather",
@@ -933,14 +937,18 @@
"traffic_no_data_this_month": "No traffic recorded for this month",
"traffic_no_data_today": "No traffic data is available for today.",
"traffic_no_traffic_today": "No traffic today",
+ "traffic_peak_hour": "Peak Hour",
"traffic_precip_summary": "{value} mm precip",
"traffic_preparing_csv": "Preparing CSV...",
"traffic_raw_records_count": "{count} raw records",
"traffic_samples": "Samples",
"traffic_samples_count": "{count} samples",
"traffic_selected_period": "Selected Period",
+ "traffic_share_of_total": "{value}% of total",
"traffic_today_only": "Today only",
+ "traffic_today_total": "Today's Total",
"traffic_today_via_coordinates": "Today via device coordinates",
+ "traffic_top_class": "Top Class",
"traffic_total_traffic": "Total Traffic",
"traffic_tracked_hours_count": "{count} tracked hour(s)",
"traffic_unavailable": "Unavailable",
@@ -1111,6 +1119,9 @@
"cwui_chart_last_sensor": "At least one sensor must stay visible",
"cwui_chart_hide_series": "Hide {label}",
"cwui_chart_show_series": "Show {label}",
+ "cwui_stacked_bar_aria": "Stacked bar chart",
+ "cwui_stacked_bar_total": "Total",
+ "cwui_stacked_bar_bar": "{label}: {total}",
"cwui_donut_aria": "Donut chart",
"cwui_donut_total": "Total",
"cwui_donut_of_total": "of {total}",
diff --git a/messages/ja.json b/messages/ja.json
index e2e55258..a7d8f4dc 100644
--- a/messages/ja.json
+++ b/messages/ja.json
@@ -927,6 +927,10 @@
"traffic_calendar_auth_required": "カレンダーデータの表示には認証済みセッションが必要です。",
"traffic_calendar_fetch_subtitle": "{monthLabel} のカレンダー取得",
"traffic_calendar_total": "交通合計: {count}",
+ "traffic_chart_hourly_subtitle": "本日・検出クラス別",
+ "traffic_chart_hourly_title": "時間別交通量",
+ "traffic_class_breakdown": "クラス別内訳",
+ "traffic_class_breakdown_subtitle": "本日の合計に対する割合",
"traffic_current_hour_today": "今日の現在時間帯",
"traffic_current_hour_total": "現在時間帯の合計",
"traffic_daily_traffic_weather": "日別交通量と天気",
@@ -949,14 +953,18 @@
"traffic_no_data_this_month": "この月の交通データは記録されていません",
"traffic_no_data_today": "今日の交通データはありません。",
"traffic_no_traffic_today": "今日は交通データがありません",
+ "traffic_peak_hour": "ピーク時間",
"traffic_precip_summary": "降水量 {value} mm",
"traffic_preparing_csv": "CSV を準備中...",
"traffic_raw_records_count": "生レコード {count} 件",
"traffic_samples": "サンプル",
"traffic_samples_count": "サンプル {count} 件",
"traffic_selected_period": "選択期間",
+ "traffic_share_of_total": "全体の{value}%",
"traffic_today_only": "今日のみ",
+ "traffic_today_total": "本日の合計",
"traffic_today_via_coordinates": "デバイス座標に基づく今日の天気",
+ "traffic_top_class": "最多クラス",
"traffic_total_traffic": "交通量合計",
"traffic_tracked_hours_count": "追跡時間 {count} 件",
"traffic_unavailable": "利用不可",
@@ -1111,6 +1119,9 @@
"cwui_chart_last_sensor": "少なくとも 1 つのセンサーを表示する必要があります",
"cwui_chart_hide_series": "{label} を非表示",
"cwui_chart_show_series": "{label} を表示",
+ "cwui_stacked_bar_aria": "積み上げ棒グラフ",
+ "cwui_stacked_bar_total": "合計",
+ "cwui_stacked_bar_bar": "{label}: {total}",
"cwui_donut_aria": "ドーナツチャート",
"cwui_donut_total": "合計",
"cwui_donut_of_total": "/ {total}",
diff --git a/package.json b/package.json
index 5f6c9107..2003223b 100644
--- a/package.json
+++ b/package.json
@@ -56,7 +56,7 @@
}
},
"dependencies": {
- "@cropwatchdevelopment/cwui": "0.1.110",
+ "@cropwatchdevelopment/cwui": "0.1.113",
"@supabase/supabase-js": "^2.108.2",
"jwt-decode": "^4.0.0"
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d0f84879..157390bb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -13,8 +13,8 @@ importers:
.:
dependencies:
'@cropwatchdevelopment/cwui':
- specifier: 0.1.110
- version: 0.1.110(svelte@5.56.3(@typescript-eslint/types@8.61.1))
+ specifier: 0.1.113
+ version: 0.1.113(svelte@5.56.3(@typescript-eslint/types@8.61.1))
'@supabase/supabase-js':
specifier: ^2.108.2
version: 2.108.2
@@ -112,8 +112,8 @@ packages:
'@blazediff/core@1.9.1':
resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==}
- '@cropwatchdevelopment/cwui@0.1.110':
- resolution: {integrity: sha512-tH8fRf6yv0eYiKXusZEcB/XdO1QPBYF0E9OOPXq3xYIrhFyA4pDrMQX3SQBqNjx1+rFQX8spCxEeUSbJ81bCqg==, tarball: https://npm.pkg.github.com/download/@cropwatchdevelopment/cwui/0.1.110/365655baf3d097e75a2b7a3539c7c4f1369b7ca9}
+ '@cropwatchdevelopment/cwui@0.1.113':
+ resolution: {integrity: sha512-FKAhB5OTb2AK4KhyQVNP3E9ASmcNlxCWKEo6gJ1RAahMWgIGIWxJeLgRDG/dlJQXn7Xd9eBPZLBG/fZ/KgHzlw==}
peerDependencies:
svelte: ^5.0.0
@@ -1946,7 +1946,7 @@ snapshots:
'@blazediff/core@1.9.1': {}
- '@cropwatchdevelopment/cwui@0.1.110(svelte@5.56.3(@typescript-eslint/types@8.61.1))':
+ '@cropwatchdevelopment/cwui@0.1.113(svelte@5.56.3(@typescript-eslint/types@8.61.1))':
dependencies:
svelte: 5.56.3(@typescript-eslint/types@8.61.1)
diff --git a/src/lib/components/dashboard/DashboardCards.svelte b/src/lib/components/dashboard/DashboardCards.svelte
index 7dd61f7a..ec3aa201 100644
--- a/src/lib/components/dashboard/DashboardCards.svelte
+++ b/src/lib/components/dashboard/DashboardCards.svelte
@@ -21,6 +21,7 @@
import type { DashboardLocationGroup, DashboardRow } from '$lib/api/api.dtos';
import { getAppContext } from '$lib/appContext.svelte';
import { isDisplayableColumn, labelFor } from '$lib/sensor-labels';
+ import { formatDateTime } from '$lib/i18n/format';
import { formatSensorMeasurement } from '$lib/units';
import { m } from '$lib/paraglide/messages.js';
import { goto } from '$app/navigation';
@@ -253,6 +254,16 @@
return Object.entries(details)
.filter(([col, value]) => isDisplayableColumn(col) && value !== null && value !== undefined)
.map(([col, value]) => {
+ // traffic_hour is the hourly bucket start timestamp — render it as a
+ // localized date+hour instead of letting the number formatter see NaN.
+ if (col === 'traffic_hour') {
+ const valueDisplay = formatDateTime(
+ String(value),
+ { month: 'short', day: 'numeric', hour: 'numeric' },
+ String(value)
+ );
+ return { col, def: labelFor(col), valueDisplay, unit: '' };
+ }
const fm = formatSensorMeasurement(col, value, app.preferences);
return { col, def: labelFor(col), valueDisplay: fm.valueDisplay, unit: fm.unit };
});
@@ -260,11 +271,17 @@
// Unit-bearing numeric metrics come back converted to the user's preference;
// booleans render as an On/Off label, unmapped columns keep their canonical unit.
+ // Integer metrics pass their formatted text as the label so the card doesn't
+ // re-format them with its 2-decimal number formatter ("0.00" for counts).
function readingProps(col: string, raw: unknown) {
const fm = formatSensorMeasurement(col, raw, app.preferences);
- if (labelFor(col).format === 'boolean') {
+ const format = labelFor(col).format;
+ if (format === 'boolean') {
return { value: null, unit: '', label: fm.valueDisplay };
}
+ if (format === 'integer' && fm.value !== null && fm.value !== undefined) {
+ return { value: fm.value, unit: fm.unit, label: fm.valueDisplay };
+ }
return { value: fm.value, unit: fm.unit, label: undefined };
}
@@ -379,6 +396,7 @@
row.device_type.default_upload_interval ??
60}
storageKey={`dashboard:${row.dev_eui}`}
+ detailsHeading={m.common_details()}
onExpand={() => loadDetails(row.dev_eui)}
>
{#if details === undefined || details === 'loading'}
@@ -388,7 +406,7 @@
{:else}
{#each detailRows as { col, def, valueDisplay, unit } (col)}
- {#if def.label() != 'created_at'}
+ {#if col !== 'created_at'}
- {def.label()}
-
diff --git a/src/lib/components/displays/TrafficDisplay/TrafficCalendarCard.svelte b/src/lib/components/displays/TrafficDisplay/TrafficCalendarCard.svelte
new file mode 100644
index 00000000..88ade091
--- /dev/null
+++ b/src/lib/components/displays/TrafficDisplay/TrafficCalendarCard.svelte
@@ -0,0 +1,355 @@
+
+
+
+
+
+
+
+ {#if calendarLoading}
+
+ {/if}
+
+ {#if calendarError}
+
+ {:else if !calendarLoading && dailySummaries.length === 0}
+
+ {/if}
+
+
+
+ {#snippet dayContent(date)}
+ {@const summary = summaryByKey[toDayKey(date)]}
+ {@const weather = weatherByDay[toDayKey(date)]}
+ {#if summary || weather}
+
+ {#if weather}
+
+
+
+ {weather.temperatureHighC !== null
+ ? `${Math.round(weather.temperatureHighC)}°`
+ : '--'}
+
+
+ {m.traffic_low_temperature({
+ value:
+ weather.temperatureLowC !== null
+ ? `${Math.round(weather.temperatureLowC)}°`
+ : '--'
+ })}
+
+
+
+
+ {weatherEmoji(weather.weatherCode)}
+ {weather.label}
+
+
+ 💧 {formatMeasurement(weather.precipitationMm, 'mm', 1)}
+
+
+ {/if}
+
+ {#if summary}
+
+
+ {m.traffic_calendar_total({ count: formatNumber(summary.totalTraffic) })}
+
+
+ {#if classKeys.length > 0}
+
+ {#each classKeys as key (key)}
+
+ {metricLabel(key)}: {formatTrafficValue(summary.classTotals[key])}
+
+ {/each}
+
+ {:else}
+
{m.traffic_no_class_data_recorded()}
+ {/if}
+
+ {/if}
+
+ {/if}
+ {/snippet}
+
+
+
+
diff --git a/src/lib/components/displays/TrafficDisplay/TrafficClassBreakdown.svelte b/src/lib/components/displays/TrafficDisplay/TrafficClassBreakdown.svelte
new file mode 100644
index 00000000..c73010e8
--- /dev/null
+++ b/src/lib/components/displays/TrafficDisplay/TrafficClassBreakdown.svelte
@@ -0,0 +1,134 @@
+
+
+{#if total > 0}
+
+ {#each rows as row (row.key)}
+
+
{metricEmoji(row.key)}
+
+
+ {metricLabelText(row.key)}
+ {formatNumber(row.value)}
+
+
+
+ {m.traffic_share_of_total({ value: row.share.toFixed(row.share >= 10 ? 0 : 1) })}
+
+
+
+ {/each}
+
+{:else}
+ {m.traffic_no_class_data_recorded()}
+{/if}
+
+
diff --git a/src/lib/components/displays/TrafficDisplay/TrafficDisplay.svelte b/src/lib/components/displays/TrafficDisplay/TrafficDisplay.svelte
index f700d36c..dfb84d67 100644
--- a/src/lib/components/displays/TrafficDisplay/TrafficDisplay.svelte
+++ b/src/lib/components/displays/TrafficDisplay/TrafficDisplay.svelte
@@ -1,496 +1,38 @@
-
-
- {#if loading && !currentHourRow}
-
-
- {m.traffic_loading_current_traffic()}
-
- {:else if currentHourRow}
-
- {formatNumber(currentHourRow.total_traffic)}
- {formatHour(currentHourRow.traffic_hour)}
-
-
-
-
-
-
- {#if trafficMetricKeys.length > 0}
-
- {#each trafficMetricKeys as key (key)}
-
- {metricLabel(key)}
- {formatTrafficValue(currentHourRow[key])}
-
- {/each}
-
- {/if}
- {:else}
- {m.traffic_no_data_today()}
- {/if}
-
-
+
+
+
0 ? m.traffic_today_only() : m.traffic_no_traffic_today()}
+ title={m.traffic_chart_hourly_title()}
+ subtitle={m.traffic_chart_hourly_subtitle()}
elevated
+ class="traffic-display__chart-card"
>
-
- {formatNumber(selectedPeriodTotal)}
- {m.traffic_tracked_hours_count({ count: formatNumber(hourlyRows.length) })}
-
-
-
-
-
-
-
- {#if trafficMetricKeys.length > 0}
-
- {#each trafficMetricKeys as key (key)}
-
- {metricLabel(key)}
- {formatTrafficValue(selectedPeriodMetricTotals[key])}
-
- {/each}
-
- {/if}
+
-
- {#if selectedWeatherLoading && !currentDaySummary}
-
-
- {m.traffic_loading_weather()}
-
- {:else if currentDaySummary}
-
-
-
- {weatherEmoji(currentDaySummary.weatherCode)}
- {formatCompactTemperature(currentDaySummary.temperatureHighC)}
-
-
- {m.traffic_low_temperature({
- value: formatCompactTemperature(currentDaySummary.temperatureLowC)
- })}
-
-
-
-
- {weatherEmoji(currentDaySummary.weatherCode)}
- {currentDaySummary.weatherLabel}
-
-
-
-
-
-
-
-
-
{currentDaySummary.weatherSummary}
-
- {:else if selectedWeatherError}
- {selectedWeatherError}
- {:else}
- {m.traffic_weather_unavailable()}
- {/if}
+
+
-
-
-
- {csvDownloading ? m.traffic_preparing_csv() : m.traffic_download_hourly_csv()}
-
-
-
- {#if loading && hourlyRows.length === 0}
-
-
- {m.traffic_fetching_traffic_data()}
-
- {:else if hourlyRows.length === 0}
- {m.traffic_no_data_today()}
- {:else}
-
- {#snippet cell(
- row: HourlyTrafficRow,
- col: CwColumnDef,
- defaultValue: string
- )}
- {#if col.key === 'traffic_hour'}
- {formatHour(row.traffic_hour)}
- {:else if col.key === 'total_traffic' || col.key === 'samples'}
- {formatTrafficValue(row[col.key])}
- {:else if col.key === 'weather_summary'}
- {row.weather_summary}
- {:else}
- {formatTrafficValue(row[col.key] ?? defaultValue)}
- {/if}
- {/snippet}
-
- {/if}
-
-
-
-
-
-
-
- {#if calendarLoading}
-
- {/if}
-
- {#if calendarError}
-
- {:else if !calendarLoading && calendarDailySummaries.length === 0}
-
- {/if}
-
-
-
- {#snippet dayContent(date)}
- {@const summary = calendarSummaryByKey[toDayKey(date)]}
- {#if summary}
-
-
-
-
- {formatCompactTemperature(summary.temperatureHighC)}
-
-
- {m.traffic_low_temperature({
- value: formatCompactTemperature(summary.temperatureLowC)
- })}
-
-
-
-
- {weatherEmoji(summary.weatherCode)}
- {summary.weatherLabel}
-
-
- 💧 {formatMeasurement(summary.precipitationMm, 'mm', 1)}
-
-
-
-
-
- {m.traffic_calendar_total({ count: formatNumber(summary.totalTraffic) })}
-
-
- {#if calendarTrafficMetricKeys.length > 0}
-
- {#each calendarTrafficMetricKeys as key (key)}
-
- {metricLabel(key)}: {formatTrafficValue(summary.classTotals[key])}
-
- {/each}
-
- {:else}
-
{m.traffic_no_class_data_recorded()}
- {/if}
-
-
- {/if}
- {/snippet}
-
-
+
+
+
diff --git a/src/lib/components/displays/TrafficDisplay/TrafficHourlyChart.svelte b/src/lib/components/displays/TrafficDisplay/TrafficHourlyChart.svelte
new file mode 100644
index 00000000..d1e0a004
--- /dev/null
+++ b/src/lib/components/displays/TrafficDisplay/TrafficHourlyChart.svelte
@@ -0,0 +1,76 @@
+
+
+
diff --git a/src/lib/components/displays/TrafficDisplay/TrafficHourlyTable.svelte b/src/lib/components/displays/TrafficDisplay/TrafficHourlyTable.svelte
new file mode 100644
index 00000000..62ef2d27
--- /dev/null
+++ b/src/lib/components/displays/TrafficDisplay/TrafficHourlyTable.svelte
@@ -0,0 +1,287 @@
+
+
+
+
+
+ {csvDownloading ? m.traffic_preparing_csv() : m.traffic_download_hourly_csv()}
+
+
+
+ {#if loading && hourlyRows.length === 0}
+ {m.traffic_fetching_traffic_data()}
+ {:else if hourlyRows.length === 0}
+ {m.traffic_no_data_today()}
+ {:else}
+
+ {#snippet cell(row: HourlyTrafficRow, col: CwColumnDef)}
+ {#if col.key === 'traffic_hour'}
+ {formatHour(row.traffic_hour)}
+ {:else}
+ {formatTrafficValue(row[col.key])}
+ {/if}
+ {/snippet}
+
+ {/if}
+
+
+
diff --git a/src/lib/components/displays/TrafficDisplay/TrafficKpiStrip.svelte b/src/lib/components/displays/TrafficDisplay/TrafficKpiStrip.svelte
new file mode 100644
index 00000000..63fe9093
--- /dev/null
+++ b/src/lib/components/displays/TrafficDisplay/TrafficKpiStrip.svelte
@@ -0,0 +1,199 @@
+
+
+
+
+
+
{m.traffic_today_total()}
+
{formatNumber(kpis.todayTotal)}
+
+ {m.traffic_tracked_hours_count({ count: formatNumber(kpis.trackedHours) })}
+
+
+
+
+
+
+ {kpis.isCurrentHour
+ ? m.traffic_current_hour_today()
+ : m.traffic_latest_available_hour_today()}
+
+ {#if loading && !kpis.currentHourRow}
+
+ {:else if kpis.currentHourRow}
+
{formatNumber(kpis.currentHourRow.total_traffic)}
+
{formatHour(kpis.currentHourRow.traffic_hour)}
+ {:else}
+
0
+
{m.traffic_no_traffic_today()}
+ {/if}
+
+
+
+
+
{m.traffic_peak_hour()}
+ {#if kpis.peakHourRow}
+
{formatNumber(kpis.peakHourRow.total_traffic)}
+
{formatHour(kpis.peakHourRow.traffic_hour)}
+ {:else}
+
--
+
{m.traffic_no_traffic_today()}
+ {/if}
+
+
+
+
+
{m.traffic_top_class()}
+ {#if kpis.topClassKey}
+
+ {metricEmoji(kpis.topClassKey)}
+ {formatNumber(kpis.classTotals[kpis.topClassKey] ?? 0)}
+
+
{metricLabelText(kpis.topClassKey)}
+ {:else}
+
--
+
{m.traffic_no_class_data_recorded()}
+ {/if}
+
+
+
+
+
{m.traffic_daily_weather()}
+ {#if weatherLoading && !weather}
+
+ {:else if weather}
+
+ {weatherEmoji(weather.weatherCode)}
+ {weather.temperatureHighC !== null ? `${Math.round(weather.temperatureHighC)}°` : '--'}
+
+ {m.traffic_low_temperature({
+ value:
+ weather.temperatureLowC !== null ? `${Math.round(weather.temperatureLowC)}°` : '--'
+ })}
+
+
+
+ {weather.label}
+ · 💧 {formatMeasurement(weather.precipitationMm, 'mm', 1)}
+ · 💨 {formatMeasurement(weather.windSpeedKmh, 'km/h')}
+
+
📍 {locationLabel}
+ {:else}
+
{weatherError ?? m.traffic_weather_unavailable()}
+ {/if}
+
+
+
+
diff --git a/src/lib/components/displays/TrafficDisplay/traffic-data.ts b/src/lib/components/displays/TrafficDisplay/traffic-data.ts
new file mode 100644
index 00000000..4a1b887a
--- /dev/null
+++ b/src/lib/components/displays/TrafficDisplay/traffic-data.ts
@@ -0,0 +1,520 @@
+/**
+ * Pure data helpers for the traffic (AI camera) display.
+ *
+ * cw_traffic2 rows are hourly accumulator buckets: one row per
+ * (dev_eui, traffic_hour, line_number), incremented in place. Bucketing here
+ * therefore keys on `traffic_hour` (falling back to the created_at hour) and
+ * sums across line_number rows that share an hour.
+ */
+import { formatDate, formatDateTime, formatNumber } from '$lib/i18n/format';
+import { m } from '$lib/paraglide/messages.js';
+
+export type TelemetryRow = Record
;
+
+export interface TrafficRow extends TelemetryRow {
+ id: string;
+ created_at: string;
+ dev_eui: string;
+}
+
+export interface HourlyTrafficRow {
+ id: string;
+ traffic_hour: string;
+ day_key: string;
+ hour_timestamp: number;
+ total_traffic: number;
+ samples: number;
+ [key: string]: string | number;
+}
+
+export interface DailyTrafficSummary {
+ dayKey: string;
+ date: Date;
+ totalTraffic: number;
+ hourCount: number;
+ sampleCount: number;
+ classTotals: Record;
+}
+
+export interface TrafficKpis {
+ todayTotal: number;
+ trackedHours: number;
+ sampleCount: number;
+ currentHourRow: HourlyTrafficRow | null;
+ /** True when currentHourRow is genuinely this hour (vs latest available). */
+ isCurrentHour: boolean;
+ peakHourRow: HourlyTrafficRow | null;
+ topClassKey: string | null;
+ classTotals: Record;
+}
+
+/**
+ * Known cw_traffic2 detection classes in fixed display order — the stack
+ * order (bottom first) and the color assignment both follow this order, so
+ * a class keeps its color no matter which classes are present or hidden.
+ */
+export const TRAFFIC_CLASS_KEYS = [
+ 'car_count',
+ 'people_count',
+ 'truck_count',
+ 'bicycle_count',
+ 'bus_count',
+ 'motorcycle_count',
+ 'train_count'
+] as const;
+
+/**
+ * Categorical palette for traffic classes (light/dark steps of the same
+ * hues), indexed by position in TRAFFIC_CLASS_KEYS. Validated for CVD
+ * separation and normal-vision distance on the CWUI light/dark surfaces.
+ * Matches CwStackedBarChart's default palette so chart and breakdown agree.
+ */
+const TRAFFIC_CLASS_COLORS = {
+ light: ['#2a78d6', '#eb6834', '#1baf7a', '#eda100', '#e87ba4', '#008300', '#4a3aa7', '#e34948'],
+ dark: ['#3987e5', '#d95926', '#199e70', '#c98500', '#d55181', '#008300', '#9085e9', '#e66767']
+} as const;
+
+export function trafficClassColor(theme: 'light' | 'dark', classIndex: number): string {
+ const palette = TRAFFIC_CLASS_COLORS[theme];
+ return palette[classIndex % palette.length];
+}
+
+const METADATA_KEYS = new Set([
+ 'id',
+ 'dev_eui',
+ 'created_at',
+ 'updated_at',
+ 'deleted_at',
+ 'is_simulated',
+ 'timestamp',
+ 'date',
+ 'time',
+ 'traffic_hour',
+ 'line_number'
+]);
+
+const WEATHER_KEYWORDS = [
+ 'weather',
+ 'condition',
+ 'summary',
+ 'description',
+ 'icon',
+ 'temp',
+ 'temperature',
+ 'humidity',
+ 'wind',
+ 'rain',
+ 'snow',
+ 'precip',
+ 'pressure',
+ 'cloud',
+ 'fog',
+ 'dew',
+ 'uv',
+ 'visibility',
+ 'barometer',
+ 'feels_like',
+ 'feelslike'
+];
+
+const PRIORITY_TRAFFIC_KEYWORDS = [
+ 'count',
+ 'traffic',
+ 'vehicle',
+ 'volume',
+ 'car',
+ 'truck',
+ 'bus',
+ 'bike',
+ 'bicycle',
+ 'ped',
+ 'pedestrian',
+ 'person',
+ 'people',
+ 'north',
+ 'south',
+ 'east',
+ 'west',
+ 'inbound',
+ 'outbound',
+ 'entry',
+ 'exit',
+ 'lane',
+ 'bound',
+ 'class',
+ 'axle'
+];
+
+/* ── Value guards ─────────────────────────────────────────────────────── */
+
+export function isRecord(value: unknown): value is TelemetryRow {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
+
+export function readNumber(value: unknown): number | null {
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value;
+ }
+
+ if (typeof value === 'string' && value.trim().length > 0) {
+ const parsed = Number(value);
+ return Number.isFinite(parsed) ? parsed : null;
+ }
+
+ return null;
+}
+
+export function readString(value: unknown): string | null {
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
+}
+
+export function parseDate(value: unknown): Date | null {
+ const text = readString(value);
+ if (!text) return null;
+
+ const parsed = new Date(text);
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
+}
+
+function normalizeKey(key: string): string {
+ return key.trim().toLowerCase();
+}
+
+/* ── Date/format helpers (all in the viewer's local timezone) ─────────── */
+
+export function toDayKey(date: Date): string {
+ const year = date.getFullYear();
+ const month = String(date.getMonth() + 1).padStart(2, '0');
+ const day = String(date.getDate()).padStart(2, '0');
+ return `${year}-${month}-${day}`;
+}
+
+export function shiftDayKey(dayKey: string, days: number): string {
+ return toDayKey(new Date(new Date(`${dayKey}T12:00:00`).getTime() + days * 86_400_000));
+}
+
+export function toHourStart(date: Date): Date {
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate(), date.getHours());
+}
+
+export function toHourKey(date: Date): string {
+ return `${toDayKey(date)} ${String(date.getHours()).padStart(2, '0')}:00`;
+}
+
+export function getDayBounds(date: Date): { start: Date; end: Date } {
+ const start = new Date(date);
+ start.setHours(0, 0, 0, 0);
+
+ const end = new Date(date);
+ end.setHours(23, 59, 59, 999);
+
+ return { start, end };
+}
+
+export function getMonthBounds(year: number, month: number): { start: Date; end: Date } {
+ const start = new Date(year, month, 1, 0, 0, 0, 0);
+ const end = new Date(year, month + 1, 0, 23, 59, 59, 999);
+ return { start, end };
+}
+
+export function formatHour(value: string | number): string {
+ const date = typeof value === 'number' ? new Date(value) : parseDate(value);
+ return date
+ ? formatDateTime(date, {
+ month: 'short',
+ day: 'numeric',
+ hour: 'numeric',
+ minute: '2-digit'
+ })
+ : String(value);
+}
+
+export function formatDay(value: Date): string {
+ return formatDate(value, {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric'
+ });
+}
+
+export function formatMeasurement(value: number | null, suffix: string, digits = 0): string {
+ return value === null ? `-- ${suffix}` : `${value.toFixed(digits)} ${suffix}`;
+}
+
+export function formatTrafficValue(value: unknown): string {
+ const numeric = readNumber(value);
+ return numeric === null ? '0' : formatNumber(numeric);
+}
+
+/* ── Class labels ─────────────────────────────────────────────────────── */
+
+export function metricEmoji(key: string): string {
+ const normalized = normalizeKey(key);
+
+ if (normalized.includes('truck')) return '🚚';
+ if (normalized.includes('bus')) return '🚌';
+ if (normalized.includes('bike') || normalized.includes('bicycle')) return '🚲';
+ if (
+ normalized.includes('ped') ||
+ normalized.includes('person') ||
+ normalized.includes('people')
+ ) {
+ return '🚶';
+ }
+ if (normalized.includes('train')) return '🚆';
+ if (normalized.includes('car') || normalized.includes('vehicle')) return '🚗';
+ if (normalized.includes('motor')) return '🏍️';
+ return '🚦';
+}
+
+const METRIC_LABEL_KEYS: Record string> = {
+ bicycle_count: () => m.traffic_metric_bicycle_count(),
+ bus_count: () => m.traffic_metric_bus_count(),
+ car_count: () => m.traffic_metric_car_count(),
+ motorcycle_count: () => m.traffic_metric_motorcycle_count(),
+ people_count: () => m.traffic_metric_people_count(),
+ train_count: () => m.traffic_metric_train_count(),
+ truck_count: () => m.traffic_metric_truck_count()
+};
+
+function prettifyKey(key: string): string {
+ return key.replaceAll('_', ' ').replace(/\b\w/g, (letter) => letter.toUpperCase());
+}
+
+/** Translated class name without the emoji prefix. */
+export function metricLabelText(key: string): string {
+ const translated = METRIC_LABEL_KEYS[key];
+ return translated ? translated() : prettifyKey(key);
+}
+
+/** Translated class name with an emoji prefix. */
+export function metricLabel(key: string): string {
+ return `${metricEmoji(key)} ${metricLabelText(key)}`;
+}
+
+/* ── Row normalization + bucketing ────────────────────────────────────── */
+
+export function normalizeTrafficRows(
+ rows: unknown[],
+ fallbackDevEui: string,
+ extraRow?: unknown
+): TrafficRow[] {
+ const seen: Record = {};
+ const result: TrafficRow[] = [];
+ const candidates = [...rows];
+
+ if (isRecord(extraRow)) {
+ candidates.push(extraRow);
+ }
+
+ for (const [index, row] of candidates.entries()) {
+ if (!isRecord(row)) continue;
+
+ const createdAt = readString(row.created_at);
+ const parsedDate = parseDate(createdAt);
+ if (!createdAt || !parsedDate) continue;
+
+ const id = String(row.id ?? row.data_id ?? `${createdAt}-${index}`);
+ const dedupeKey = `${id}:${createdAt}`;
+ if (seen[dedupeKey]) continue;
+
+ seen[dedupeKey] = true;
+ result.push({
+ ...row,
+ id,
+ created_at: createdAt,
+ dev_eui: String(row.dev_eui ?? fallbackDevEui ?? '')
+ });
+ }
+
+ return result.sort(
+ (left, right) => new Date(left.created_at).getTime() - new Date(right.created_at).getTime()
+ );
+}
+
+function isWeatherKey(key: string): boolean {
+ const normalized = normalizeKey(key);
+ if (METADATA_KEYS.has(normalized)) return false;
+ return WEATHER_KEYWORDS.some((keyword) => normalized.includes(keyword));
+}
+
+function isPriorityTrafficKey(key: string): boolean {
+ const normalized = normalizeKey(key);
+ return PRIORITY_TRAFFIC_KEYWORDS.some((keyword) => normalized.includes(keyword));
+}
+
+/**
+ * Detection classes present in the rows, in TRAFFIC_CLASS_KEYS order. Unknown
+ * schemas fall back to keyword discovery over numeric columns.
+ */
+export function resolveTrafficClassKeys(rows: TrafficRow[]): string[] {
+ const present = TRAFFIC_CLASS_KEYS.filter((key) =>
+ rows.some((row) => readNumber(row[key]) !== null)
+ );
+ if (present.length > 0) return [...present];
+
+ const numericKeyMap: Record = {};
+ for (const row of rows) {
+ for (const [key, value] of Object.entries(row)) {
+ const normalized = normalizeKey(key);
+ if (METADATA_KEYS.has(normalized) || isWeatherKey(key)) continue;
+ if (readNumber(value) === null) continue;
+ numericKeyMap[key] = true;
+ }
+ }
+
+ const keys = Object.keys(numericKeyMap);
+ const prioritized = keys.filter((key) => isPriorityTrafficKey(key));
+ return (prioritized.length > 0 ? prioritized : keys).sort((left, right) =>
+ left.localeCompare(right)
+ );
+}
+
+export function sumTrafficValues(row: TrafficRow, classKeys: string[]): number {
+ if (classKeys.length === 0) {
+ return 1;
+ }
+
+ return classKeys.reduce((total, key) => total + (readNumber(row[key]) ?? 0), 0);
+}
+
+/**
+ * The bucket-start instant for a row: `traffic_hour` when present (the true
+ * accumulator key), otherwise the created_at hour floor.
+ */
+export function rowHourStart(row: TrafficRow): Date | null {
+ const trafficHour = parseDate(row.traffic_hour);
+ if (trafficHour) return trafficHour;
+
+ const createdAt = parseDate(row.created_at);
+ return createdAt ? toHourStart(createdAt) : null;
+}
+
+export function buildHourlyTrafficRows(
+ rows: TrafficRow[],
+ classKeys: string[]
+): HourlyTrafficRow[] {
+ const buckets: Record = {};
+
+ for (const row of rows) {
+ const hourStart = rowHourStart(row);
+ if (!hourStart) continue;
+
+ const hourKey = toHourKey(hourStart);
+ let bucket = buckets[hourKey];
+
+ if (!bucket) {
+ bucket = {
+ id: hourKey,
+ traffic_hour: hourStart.toISOString(),
+ day_key: toDayKey(hourStart),
+ hour_timestamp: hourStart.getTime(),
+ total_traffic: 0,
+ samples: 0
+ };
+
+ for (const key of classKeys) {
+ bucket[key] = 0;
+ }
+
+ buckets[hourKey] = bucket;
+ }
+
+ bucket.samples += 1;
+ bucket.total_traffic += sumTrafficValues(row, classKeys);
+
+ for (const key of classKeys) {
+ bucket[key] = (readNumber(bucket[key]) ?? 0) + (readNumber(row[key]) ?? 0);
+ }
+ }
+
+ return Object.values(buckets).sort((left, right) => left.hour_timestamp - right.hour_timestamp);
+}
+
+export function buildDailyTrafficSummaries(
+ rows: TrafficRow[],
+ classKeys: string[]
+): DailyTrafficSummary[] {
+ type DailyAccumulator = DailyTrafficSummary & { hourKeys: Record };
+
+ const dailyTotals: Record = {};
+
+ for (const row of rows) {
+ const hourStart = rowHourStart(row);
+ if (!hourStart) continue;
+
+ const dayKey = toDayKey(hourStart);
+ let daily = dailyTotals[dayKey];
+ if (!daily) {
+ daily = {
+ dayKey,
+ date: new Date(hourStart),
+ totalTraffic: 0,
+ hourCount: 0,
+ sampleCount: 0,
+ hourKeys: {},
+ classTotals: Object.fromEntries(classKeys.map((key) => [key, 0]))
+ };
+ dailyTotals[dayKey] = daily;
+ }
+
+ daily.totalTraffic += sumTrafficValues(row, classKeys);
+ daily.sampleCount += 1;
+ daily.hourKeys[toHourKey(hourStart)] = true;
+ for (const key of classKeys) {
+ daily.classTotals[key] = (daily.classTotals[key] ?? 0) + (readNumber(row[key]) ?? 0);
+ }
+ }
+
+ return Object.values(dailyTotals)
+ .sort((left, right) => left.date.getTime() - right.date.getTime())
+ .map(({ hourKeys, ...daily }) => ({ ...daily, hourCount: Object.keys(hourKeys).length }));
+}
+
+export function computeTrafficKpis(
+ hourlyRows: HourlyTrafficRow[],
+ classKeys: string[],
+ now: Date
+): TrafficKpis {
+ const todayTotal = hourlyRows.reduce((total, row) => total + row.total_traffic, 0);
+ const sampleCount = hourlyRows.reduce((total, row) => total + row.samples, 0);
+
+ const currentHourKey = toHourKey(toHourStart(now));
+ const exactCurrent = hourlyRows.find((row) => row.id === currentHourKey) ?? null;
+ const currentHourRow = exactCurrent ?? hourlyRows[hourlyRows.length - 1] ?? null;
+
+ let peakHourRow: HourlyTrafficRow | null = null;
+ for (const row of hourlyRows) {
+ if (!peakHourRow || row.total_traffic > peakHourRow.total_traffic) {
+ peakHourRow = row;
+ }
+ }
+
+ const classTotals: Record = Object.fromEntries(
+ classKeys.map((key) => [
+ key,
+ hourlyRows.reduce((total, row) => total + (readNumber(row[key]) ?? 0), 0)
+ ])
+ );
+
+ let topClassKey: string | null = null;
+ for (const key of classKeys) {
+ if (topClassKey === null || (classTotals[key] ?? 0) > (classTotals[topClassKey] ?? 0)) {
+ topClassKey = key;
+ }
+ }
+ if (topClassKey !== null && (classTotals[topClassKey] ?? 0) === 0) {
+ topClassKey = null;
+ }
+
+ return {
+ todayTotal,
+ trackedHours: hourlyRows.length,
+ sampleCount,
+ currentHourRow,
+ isCurrentHour: exactCurrent !== null,
+ peakHourRow,
+ topClassKey,
+ classTotals
+ };
+}
diff --git a/src/lib/components/displays/TrafficDisplay/traffic-weather.ts b/src/lib/components/displays/TrafficDisplay/traffic-weather.ts
new file mode 100644
index 00000000..5b04f65b
--- /dev/null
+++ b/src/lib/components/displays/TrafficDisplay/traffic-weather.ts
@@ -0,0 +1,223 @@
+/**
+ * Client-side daily weather for the traffic display, fetched directly from
+ * Open-Meteo (forecast API for today/future days, archive API for the past)
+ * and cached per coordinates + day range.
+ */
+import type { CwTone } from '@cropwatchdevelopment/cwui';
+import { SvelteMap } from 'svelte/reactivity';
+import { m } from '$lib/paraglide/messages.js';
+import { isRecord, readNumber, readString, shiftDayKey, toDayKey } from './traffic-data';
+
+export interface WeatherDaySummary {
+ dayKey: string;
+ summary: string;
+ label: string;
+ tone: CwTone;
+ temperatureLabel: string | null;
+ temperatureHighC: number | null;
+ temperatureLowC: number | null;
+ precipitationMm: number | null;
+ windSpeedKmh: number | null;
+ weatherCode: number | null;
+}
+
+const OPEN_METEO_FORECAST_URL = 'https://api.open-meteo.com/v1/forecast';
+const OPEN_METEO_ARCHIVE_URL = 'https://archive-api.open-meteo.com/v1/archive';
+const OPEN_METEO_DAILY_FIELDS =
+ 'weather_code,temperature_2m_max,temperature_2m_min,precipitation_sum,wind_speed_10m_max';
+
+export function weatherEmoji(code: number | null): string {
+ if (code === 0 || code === 1) return '☀️';
+ if (code === 2) return '🌤️';
+ if (code === 3) return '☁️';
+ if (code === 45 || code === 48) return '🌫️';
+ if ([51, 53, 55, 56, 57].includes(code ?? -1)) return '🌦️';
+ if ([61, 63, 65, 66, 67, 80, 81, 82].includes(code ?? -1)) return '🌧️';
+ if ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) return '❄️';
+ if ([95, 96, 99].includes(code ?? -1)) return '⛈️';
+ return '🌡️';
+}
+
+export function describeWeatherCode(code: number | null): {
+ label: string;
+ description: string;
+ tone: CwTone;
+} {
+ if (code === 0) {
+ return { label: m.weather_clear(), description: m.weather_clear_sky(), tone: 'success' };
+ }
+
+ if (code === 1) {
+ return {
+ label: m.weather_mostly_clear(),
+ description: m.weather_mostly_clear_description(),
+ tone: 'success'
+ };
+ }
+
+ if (code === 2) {
+ return {
+ label: m.weather_partly_cloudy(),
+ description: m.weather_partly_cloudy_description(),
+ tone: 'info'
+ };
+ }
+
+ if (code === 3) {
+ return { label: m.weather_cloudy(), description: m.weather_overcast(), tone: 'secondary' };
+ }
+
+ if (code === 45 || code === 48) {
+ return { label: m.weather_fog(), description: m.weather_foggy(), tone: 'secondary' };
+ }
+
+ if ([51, 53, 55, 56, 57].includes(code ?? -1)) {
+ return { label: m.weather_drizzle(), description: m.weather_drizzle(), tone: 'info' };
+ }
+
+ if ([61, 63, 65, 66, 67, 80, 81, 82].includes(code ?? -1)) {
+ return { label: m.weather_rain(), description: m.weather_rain(), tone: 'info' };
+ }
+
+ if ([71, 73, 75, 77, 85, 86].includes(code ?? -1)) {
+ return { label: m.weather_snow(), description: m.weather_snow(), tone: 'secondary' };
+ }
+
+ if ([95, 96, 99].includes(code ?? -1)) {
+ return {
+ label: m.weather_storm(),
+ description: m.weather_thunderstorm(),
+ tone: 'danger'
+ };
+ }
+
+ return {
+ label: m.common_weather(),
+ description: m.traffic_weather_unavailable(),
+ tone: 'secondary'
+ };
+}
+
+function buildWeatherDaySummary(
+ dayKey: string,
+ weatherCode: number | null,
+ temperatureHighC: number | null,
+ temperatureLowC: number | null,
+ precipitationMm: number | null,
+ windSpeedKmh: number | null
+): WeatherDaySummary {
+ const descriptor = describeWeatherCode(weatherCode);
+ const temperatureLabel =
+ temperatureHighC !== null || temperatureLowC !== null
+ ? `${temperatureHighC !== null ? temperatureHighC.toFixed(0) : '--'}° / ${temperatureLowC !== null ? temperatureLowC.toFixed(0) : '--'}°C`
+ : null;
+
+ const summaryParts = [descriptor.description];
+ if (temperatureLabel) {
+ summaryParts.push(temperatureLabel);
+ }
+
+ if (precipitationMm !== null) {
+ summaryParts.push(m.traffic_precip_summary({ value: precipitationMm.toFixed(1) }));
+ }
+
+ if (windSpeedKmh !== null) {
+ summaryParts.push(m.traffic_wind_summary({ value: windSpeedKmh.toFixed(0) }));
+ }
+
+ return {
+ dayKey,
+ summary: summaryParts.join(' • '),
+ label: descriptor.label,
+ tone: descriptor.tone,
+ temperatureLabel,
+ temperatureHighC,
+ temperatureLowC,
+ precipitationMm,
+ windSpeedKmh,
+ weatherCode
+ };
+}
+
+function normalizeWeatherPayload(payload: unknown): Record {
+ if (!isRecord(payload) || !isRecord(payload.daily) || !Array.isArray(payload.daily.time)) {
+ return {};
+ }
+
+ const daily = payload.daily as Record & { time: unknown[] };
+ const result: Record = {};
+
+ for (const [index, rawDayKey] of daily.time.entries()) {
+ const dayKey = readString(rawDayKey);
+ if (!dayKey) continue;
+
+ result[dayKey] = buildWeatherDaySummary(
+ dayKey,
+ readNumber(Array.isArray(daily.weather_code) ? daily.weather_code[index] : null),
+ readNumber(Array.isArray(daily.temperature_2m_max) ? daily.temperature_2m_max[index] : null),
+ readNumber(Array.isArray(daily.temperature_2m_min) ? daily.temperature_2m_min[index] : null),
+ readNumber(Array.isArray(daily.precipitation_sum) ? daily.precipitation_sum[index] : null),
+ readNumber(Array.isArray(daily.wind_speed_10m_max) ? daily.wind_speed_10m_max[index] : null)
+ );
+ }
+
+ return result;
+}
+
+const weatherCache = new SvelteMap>();
+
+export async function fetchWeatherByRange(
+ startDate: Date,
+ endDate: Date,
+ latitude: number,
+ longitude: number
+): Promise> {
+ const startDay = toDayKey(startDate);
+ const endDay = toDayKey(endDate);
+ const cacheKey = `${latitude.toFixed(4)}:${longitude.toFixed(4)}:${startDay}:${endDay}`;
+ const cached = weatherCache.get(cacheKey);
+ if (cached) {
+ return cached;
+ }
+
+ const todayKey = toDayKey(new Date());
+ const requests: Promise>[] = [];
+ const addRequest = (baseUrl: string, requestStart: string, requestEnd: string) => {
+ if (requestStart > requestEnd) return;
+
+ const url = new URL(baseUrl);
+ url.searchParams.set('latitude', String(latitude));
+ url.searchParams.set('longitude', String(longitude));
+ url.searchParams.set('daily', OPEN_METEO_DAILY_FIELDS);
+ url.searchParams.set('timezone', 'auto');
+ url.searchParams.set('start_date', requestStart);
+ url.searchParams.set('end_date', requestEnd);
+
+ requests.push(
+ fetch(url.toString()).then(async (response) => {
+ if (!response.ok) {
+ throw new Error(`Weather request failed with ${response.status}`);
+ }
+
+ return normalizeWeatherPayload(await response.json());
+ })
+ );
+ };
+
+ if (endDay < todayKey) {
+ addRequest(OPEN_METEO_ARCHIVE_URL, startDay, endDay);
+ } else if (startDay >= todayKey) {
+ addRequest(OPEN_METEO_FORECAST_URL, startDay, endDay);
+ } else {
+ addRequest(OPEN_METEO_ARCHIVE_URL, startDay, shiftDayKey(todayKey, -1));
+ addRequest(OPEN_METEO_FORECAST_URL, todayKey, endDay);
+ }
+
+ const merged: Record = {};
+ for (const weatherResult of await Promise.all(requests)) {
+ Object.assign(merged, weatherResult);
+ }
+
+ weatherCache.set(cacheKey, merged);
+ return merged;
+}
diff --git a/src/lib/i18n/cwuiLabels.ts b/src/lib/i18n/cwuiLabels.ts
index fe855522..b4a719db 100644
--- a/src/lib/i18n/cwuiLabels.ts
+++ b/src/lib/i18n/cwuiLabels.ts
@@ -13,6 +13,7 @@ import type {
CwDataTableLabels,
CwResponsiveLineChartLabels,
CwDonutChartLabels,
+ CwStackedBarChartLabels,
CwHeatmapLabels,
CwPPFDChartLabels,
DliCardLabels,
@@ -91,6 +92,14 @@ export function cwDonutChartLabels(): CwDonutChartLabels {
};
}
+export function cwStackedBarChartLabels(): CwStackedBarChartLabels {
+ return {
+ ariaLabel: m.cwui_stacked_bar_aria(),
+ total: m.cwui_stacked_bar_total(),
+ barLabel: (label, total) => m.cwui_stacked_bar_bar({ label, total })
+ };
+}
+
export function cwHeatmapLabels(): CwHeatmapLabels {
return {
title: m.cwui_heatmap_title(),
diff --git a/src/lib/sensor-labels/index.ts b/src/lib/sensor-labels/index.ts
index de58dae5..965ac635 100644
--- a/src/lib/sensor-labels/index.ts
+++ b/src/lib/sensor-labels/index.ts
@@ -44,10 +44,21 @@ const SENSOR_LABELS: Record = {
relay_1: { label: () => m.sensor_relay_1(), unit: '', format: 'boolean' },
relay_2: { label: () => m.sensor_relay_2(), unit: '', format: 'boolean' },
people_count: { label: () => m.sensor_people_count(), unit: '', format: 'integer' },
- car_count: { label: () => m.sensor_car_count(), unit: '', format: 'integer' }
+ car_count: { label: () => m.sensor_car_count(), unit: '', format: 'integer' },
+ bicycle_count: { label: () => m.traffic_metric_bicycle_count(), unit: '', format: 'integer' },
+ bus_count: { label: () => m.traffic_metric_bus_count(), unit: '', format: 'integer' },
+ motorcycle_count: {
+ label: () => m.traffic_metric_motorcycle_count(),
+ unit: '',
+ format: 'integer'
+ },
+ train_count: { label: () => m.traffic_metric_train_count(), unit: '', format: 'integer' },
+ truck_count: { label: () => m.traffic_metric_truck_count(), unit: '', format: 'integer' },
+ // Hourly accumulator bucket start (cw_traffic2) — a timestamp, not a reading.
+ traffic_hour: { label: () => m.traffic_hour(), unit: '', format: 'number' }
};
-const HIDDEN_COLUMNS = new Set(['dev_eui', 'id', 'is_simulated', 'last_update']);
+const HIDDEN_COLUMNS = new Set(['dev_eui', 'id', 'is_simulated', 'last_update', 'line_number']);
export function labelFor(column: string): SensorLabel {
return SENSOR_LABELS[column] ?? { label: () => column, unit: '', format: 'number' };
diff --git a/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte b/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte
index 93a41bcf..23483456 100644
--- a/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte
+++ b/src/routes/locations/[location_id]/devices/[dev_eui]/+page.svelte
@@ -2,7 +2,7 @@
import { AppPage } from '$lib/components/layout';
import { afterNavigate } from '$app/navigation';
import { getAppContext } from '$lib/appContext.svelte';
- import { isRelayTable, resolveDisplayComponent } from '$lib/config/deviceTables';
+ import { isRelayTable, isTrafficTable, resolveDisplayComponent } from '$lib/config/deviceTables';
import { ApiService, ApiServiceError } from '$lib/api/api.service';
import { readApiErrorMessage } from '$lib/api/api-error';
import {
@@ -146,7 +146,7 @@
// Dew point is opt-in per page view: it mounts hidden and the user shows it
// via the chart legend.
let chartInitialHidden = $derived(isAirDevice ? [DEW_POINT_SERIES_ID] : []);
- let isTrafficDevice = $derived(data.device?.cw_device_type.name === '[CROPWATCH] Nvidia Jetson');
+ let isTrafficDevice = $derived(isTrafficTable(data.dataTable));
let rangeOptions = $derived(getRangeOptions());
let lastUpdatedAt = $derived(readCreatedAt(displayCurrentRecord));
let noDataYet = $derived(
@@ -487,7 +487,10 @@
titleName={data?.device?.name || devEui.toUpperCase()}
/>
- {#if chartSeries.length > 0}
+
+ {#if chartSeries.length > 0 && !isTrafficDevice}