From 86eb80eb76ea793085a1a632ce740111335d4858 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Mon, 27 Jul 2026 18:26:47 +0900 Subject: [PATCH] feat(traffic): redesign AI-camera detail page + fix dashboard traffic cards Detail page (cw_traffic2 devices): - Replace the generic 8-series line chart with a purpose-built hourly stacked bar chart (new CwStackedBarChart from cwui 0.1.113), 00-23 axis, translated legend, per-class colors validated for colorblind safety - New KPI strip: today's total, current hour, peak hour, top class, and a compact daily-weather tile - New class-breakdown card with share-of-total bars matching chart colors - Hourly table: translated per-class headers (was raw prettified English), buckets keyed by traffic_hour instead of created_at hour, dead per-row weather column removed (weather stays in KPI tile, calendar, and CSV) - Decompose the 1677-line TrafficDisplay.svelte into focused modules: traffic-data.ts, traffic-weather.ts, TrafficKpiStrip, TrafficHourlyChart, TrafficClassBreakdown, TrafficHourlyTable, TrafficCalendarCard - Detect traffic devices via isTrafficTable(dataTable) instead of an exact device-type-name match; suppress the generic chart for traffic devices - Replace the 250ms polling loop with a 60s clock tick + keyed effects - Calendar month nav shows loading spinner (CwCalendar loading prop) Dashboard cards (visual design unchanged): - Translate all traffic metric labels via existing traffic_metric_* keys (sensor-labels now covers all 7 detection classes; fixes DashboardTable too) - Integer metrics render without the forced 2-decimal format (no more 0.00) - traffic_hour renders as a localized date+hour instead of a raw UTC ISO string; line_number hidden; details heading localized - created_at row filter now guards on the column key, not the label text Infra: - cwui 0.1.110 -> 0.1.113, now installed from registry.npmjs.org (scope routing to GitHub Packages removed; package is public, installs need no NODE_AUTH_TOKEN) Requires the API dashboard aggregation change (deploy API first): dashboard values for cw_traffic2 are today's totals summed across line_number buckets; the previous latest-row read returned a near-empty accumulator bucket. Co-Authored-By: Claude Fable 5 --- .npmrc | 3 - messages/en.json | 11 + messages/ja.json | 11 + package.json | 2 +- pnpm-lock.yaml | 10 +- .../dashboard/DashboardCards.svelte | 22 +- .../TrafficDisplay/TrafficCalendarCard.svelte | 355 ++++ .../TrafficClassBreakdown.svelte | 134 ++ .../TrafficDisplay/TrafficDisplay.svelte | 1695 ++--------------- .../TrafficDisplay/TrafficHourlyChart.svelte | 76 + .../TrafficDisplay/TrafficHourlyTable.svelte | 287 +++ .../TrafficDisplay/TrafficKpiStrip.svelte | 199 ++ .../displays/TrafficDisplay/traffic-data.ts | 520 +++++ .../TrafficDisplay/traffic-weather.ts | 223 +++ src/lib/i18n/cwuiLabels.ts | 9 + src/lib/sensor-labels/index.ts | 15 +- .../devices/[dev_eui]/+page.svelte | 9 +- .../devices/[dev_eui]/device-detail.ts | 3 +- 18 files changed, 1992 insertions(+), 1592 deletions(-) create mode 100644 src/lib/components/displays/TrafficDisplay/TrafficCalendarCard.svelte create mode 100644 src/lib/components/displays/TrafficDisplay/TrafficClassBreakdown.svelte create mode 100644 src/lib/components/displays/TrafficDisplay/TrafficHourlyChart.svelte create mode 100644 src/lib/components/displays/TrafficDisplay/TrafficHourlyTable.svelte create mode 100644 src/lib/components/displays/TrafficDisplay/TrafficKpiStrip.svelte create mode 100644 src/lib/components/displays/TrafficDisplay/traffic-data.ts create mode 100644 src/lib/components/displays/TrafficDisplay/traffic-weather.ts 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)} +
+ +
+
+ {metricLabelText(row.key)} + {formatNumber(row.value)} +
+
+
+
+ +
+
+ {/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} +

+ + {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} +

+ + {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}