export const TRASH_REGION_ID = "trash"; export const MIN_FOOD_ZONE_COUNT = 1; export const MAX_FOOD_ZONE_COUNT = 10; export const DEFAULT_FOOD_ZONE_COUNT = 8; const DEFAULT_RUNTIME_THRESHOLD_SECONDS = 1200; const zonePalette = [ "#d92d20", "#b54708", "#4e5ba6", "#008a5a", "#0077a3", "#155eef", "#7f56d9", "#c11574", "#4f7f1f", "#8c5a00", ]; export function deriveFoodZones(config = {}) { const layout = config.layout || {}; const sourceZones = config.zones || []; const configuredIds = normalizeZoneIds(layout.zone_ids); const numericIds = configuredIds.filter(isNumericId); const sourceZonesById = new Map(sourceZones.map((zone) => [String(zone.id || ""), zone])); const count = deriveZoneCount(layout, configuredIds, sourceZones); const legacyIds = deriveLegacySourceIds(layout, configuredIds, sourceZones, count); return numericZoneIds(count).map((id, index) => { const legacySourceId = legacyIds[index]; const numericSourceId = numericIds.includes(id) ? id : ""; const source = sourceZonesById.get(numericSourceId) || sourceZonesById.get(legacySourceId) || {}; return { id, label: `区域 ${id}`, sourceId: String(source.id || numericSourceId || legacySourceId || id), polygon: normalizePolygon(source.polygon), }; }); } export function deriveZoneCount(layout = {}, configuredIds = normalizeZoneIds(layout.zone_ids), zones = []) { if (configuredIds.length) { return clampZoneCount(configuredIds.length); } if (layout.zone_count !== undefined) { return clampZoneCount(layout.zone_count); } const rows = Number(layout.rows); const cols = Number(layout.cols); if (Number.isFinite(rows) && Number.isFinite(cols) && rows > 0 && cols > 0) { return clampZoneCount(rows * cols); } if (Array.isArray(zones) && zones.length) { return clampZoneCount(zones.length); } return DEFAULT_FOOD_ZONE_COUNT; } export function clampZoneCount(value, fallback = DEFAULT_FOOD_ZONE_COUNT) { const parsed = Number(value); const count = Number.isFinite(parsed) ? Math.trunc(parsed) : fallback; return Math.min(MAX_FOOD_ZONE_COUNT, Math.max(MIN_FOOD_ZONE_COUNT, count)); } export function numericZoneIds(count) { return Array.from({length: clampZoneCount(count)}, (_, index) => String(index + 1)); } export function createEmptyPolygonMap(foodZones) { return Object.fromEntries([...foodZones.map((zone) => [zone.id, []]), [TRASH_REGION_ID, []]]); } export function buildPolygonMap(foodZones, existing = {}, trashRoi = []) { const polygons = createEmptyPolygonMap(foodZones); for (const zone of foodZones) { const existingPolygon = normalizePolygon(existing[zone.id]); polygons[zone.id] = existingPolygon.length ? existingPolygon : normalizePolygon(zone.polygon); } const existingTrash = normalizePolygon(existing[TRASH_REGION_ID]); polygons[TRASH_REGION_ID] = existingTrash.length ? existingTrash : normalizePolygon(trashRoi); return polygons; } export function buildCalibrationPayload(foodZones, polygons) { const zones = foodZones .map((zone) => ({ id: zone.id, label: getRegionLabel(zone.id), polygon: serializePolygon(polygons[zone.id]), })) .filter((zone) => zone.polygon.length >= 3); const trashPolygon = serializePolygon(polygons[TRASH_REGION_ID]); return { layout: { zone_count: foodZones.length, zone_ids: foodZones.map((zone) => zone.id), }, zones, trash: trashPolygon.length >= 3 ? {roi: trashPolygon} : {}, }; } export function classifyEvent(event = {}) { const eventName = String(event.event || ""); const severity = String(event.severity || defaultSeverity(eventName)).toLowerCase(); const zoneIndex = deriveEventZoneIndex(event); const zoneLabel = String(event.zone_label || (zoneIndex ? `区域 ${zoneIndex}` : event.zone_id || "")); const isAlert = severity === "alarm" || eventName === "time_alarm"; const isWarning = severity === "warning" || eventName === "warning_escalated" || eventName.endsWith("_violation"); const isViolation = eventName === "warning_escalated" || eventName.endsWith("_violation") || event.state === "warning"; return { severity, tone: isWarning ? "warning" : isAlert ? "alarm" : "info", zoneIndex, zoneLabel, isAlert, isWarning, isViolation, }; } export function buildRuntimeDisplayModel({ summary = null, events = [], config = {}, foodZones = deriveFoodZones(config), demoReason = "", now = new Date(), } = {}) { const safeConfig = config || {}; const realEvents = (Array.isArray(events) ? events : []).filter((event) => !isDemoRuntimeEvent(event)); const hasEvents = realEvents.length > 0; const hasSummary = hasRuntimeSummary(summary) && !isDemoRuntimeSummary(summary); const thresholdSeconds = runtimeThresholdSeconds(safeConfig, realEvents); const displaySummary = hasSummary ? summary : createEmptyRuntimeSummary(thresholdSeconds); const displayEvents = buildDisplayEvents(realEvents, now); const latestZoneCounts = displaySummary?.metrics?.latest_zone_counts || {}; const configuredZoneIndexes = new Set(foodZones.map((zone) => Number(zone.id)).filter((id) => Number.isFinite(id))); const progressRows = hasEvents ? buildProgressRowsFromEvents(realEvents, thresholdSeconds, now) .filter((row) => configuredZoneIndexes.size === 0 || configuredZoneIndexes.has(row.zoneIndex)) .filter((row) => zoneCurrentlyOccupied(latestZoneCounts, row.zoneIndex)) : []; return { isDemo: false, summaryIsDemo: false, eventsAreDemo: false, progressIsDemo: false, hasSummary, hasEvents, demoReason, summary: displaySummary, events: realEvents, displayEvents, progressRows, }; } export function getRegionColor(id) { if (id === TRASH_REGION_ID) { return "#111827"; } const index = Number(id) - 1; return zonePalette[index] || "#667085"; } export function getRegionLabel(id) { if (id === TRASH_REGION_ID) { return "垃圾桶"; } if (isNumericId(id)) { return `区域 ${id}`; } const match = String(id).match(/^r(\d)c(\d)$/); return match ? `${match[1]}排${match[2]}列` : String(id); } export function secondsToAlarmMinutes(seconds) { const parsed = Number(seconds); if (!Number.isFinite(parsed) || parsed <= 0) { return 1; } return Math.max(1, Math.round(parsed / 60)); } export function alarmMinutesToSeconds(minutes) { const parsed = Number(minutes); if (!Number.isFinite(parsed) || parsed <= 0) { return 60; } return Math.max(60, Math.round(parsed * 60)); } export function escapeHtml(value) { return String(value).replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[char]); } function hasRuntimeSummary(summary) { const metrics = summary?.metrics; return Boolean(metrics && typeof metrics === "object"); } function isDemoRuntimeSummary(summary) { return containsDemoMarker(summary?.result_type) || containsDemoMarker(summary?.headline); } function isDemoRuntimeEvent(event) { return event?.demo === true || containsDemoMarker(event?.camera_id) || containsDemoMarker(event?.batch_id); } function containsDemoMarker(value) { const text = String(value || "").toLowerCase(); return text.includes("demo") || text.includes("演示"); } function createEmptyRuntimeSummary(thresholdSeconds) { return { result_type: "cold_display_guard", headline: "暂无事件数据", last_result_time: "", metrics: { event_counts: {}, event_count: 0, alert_count: 0, warning_count: 0, violation_count: 0, latest_alert_time: "", events_path: "-", diagnostics_path: "-", diagnostics_count: 0, latest_zone_counts: {}, baseline_ready: false, max_dwell_seconds: thresholdSeconds, }, }; } function buildDisplayEvents(events, now) { const liveEventOrdersByBatch = latestLiveEventOrdersByBatch(events); return events.map((event, order) => ({ ...event, displayDwellSeconds: displayEventDwellSeconds(event, order, liveEventOrdersByBatch, now), })); } function displayEventDwellSeconds(event, order, liveEventOrdersByBatch, now) { const fallbackSeconds = normalizeSeconds(event.dwell_seconds); const batchId = String(event.batch_id || ""); if (!batchId || liveEventOrdersByBatch.get(batchId) !== order) { return fallbackSeconds; } return liveDwellSeconds(event, fallbackSeconds, now); } function latestLiveEventOrdersByBatch(events) { const latestByBatch = new Map(); events.forEach((event, order) => { const batchId = String(event.batch_id || ""); if (!batchId) { return; } const candidate = { event, eventTime: eventTimestamp(event), order, }; const existing = latestByBatch.get(batchId); if (!existing || isNewerEventCandidate(candidate, existing)) { latestByBatch.set(batchId, candidate); } }); const liveOrders = new Map(); latestByBatch.forEach((candidate, batchId) => { if (isLiveBatchEvent(candidate.event)) { liveOrders.set(batchId, candidate.order); } }); return liveOrders; } function zoneCurrentlyOccupied(latestZoneCounts, zoneIndex) { if (!latestZoneCounts || typeof latestZoneCounts !== "object") { return true; } if (Object.keys(latestZoneCounts).length === 0) { return true; } const count = latestZoneCounts[String(zoneIndex)]; if (count === undefined) { return false; } return Number(count) > 0; } function buildProgressRowsFromEvents(events, thresholdSeconds, now) { const candidatesByZone = new Map(); events.forEach((event, order) => { const meta = classifyEvent(event); if (!meta.zoneIndex) { return; } const dwellSeconds = liveDwellSeconds(event, normalizeSeconds(event.dwell_seconds), now); const threshold = normalizeSeconds(event.max_dwell_seconds) || thresholdSeconds; const existing = candidatesByZone.get(meta.zoneIndex); const row = { zoneIndex: meta.zoneIndex, zoneLabel: meta.zoneLabel || `区域 ${meta.zoneIndex}`, dwellSeconds, thresholdSeconds: threshold, progressPct: progressPct(dwellSeconds, threshold), status: progressStatus(event, dwellSeconds, threshold), source: "real", }; const candidate = { row, eventTime: eventTimestamp(event), order, }; if (!existing || isNewerEventCandidate(candidate, existing)) { candidatesByZone.set(meta.zoneIndex, candidate); } }); return [...candidatesByZone.values()].map((candidate) => candidate.row).sort((a, b) => a.zoneIndex - b.zoneIndex); } function runtimeThresholdSeconds(config = {}, events = []) { const fromConfig = normalizeSeconds(config.thresholds?.max_dwell_seconds); if (fromConfig > 0) { return fromConfig; } const fromEvent = events.map((event) => normalizeSeconds(event.max_dwell_seconds)).find((seconds) => seconds > 0); return fromEvent || DEFAULT_RUNTIME_THRESHOLD_SECONDS; } function progressStatus(event, dwellSeconds, thresholdSeconds) { const meta = classifyEvent(event); if (meta.isWarning || meta.isViolation) { return "warning"; } if (meta.isAlert || dwellSeconds >= thresholdSeconds) { return "alarm"; } return "normal"; } function progressPct(dwellSeconds, thresholdSeconds) { if (!thresholdSeconds) { return 0; } return Math.min(100, Math.round((dwellSeconds / thresholdSeconds) * 100)); } function normalizeSeconds(value) { const parsed = Number(value); return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : 0; } function eventTimestamp(event) { const parsed = timestampMillis(event.ts); return Number.isFinite(parsed) ? parsed : null; } function liveDwellSeconds(event, fallbackSeconds, now) { if (!isLiveBatchEvent(event)) { return fallbackSeconds; } const startedAt = timestampMillis(event.started_at); const nowAt = timestampMillis(now); if (!Number.isFinite(startedAt) || !Number.isFinite(nowAt) || nowAt < startedAt) { return fallbackSeconds; } return Math.max(fallbackSeconds, Math.round((nowAt - startedAt) / 1000)); } function isLiveBatchEvent(event = {}) { const terminalEvents = new Set([ "batch_consumed", "batch_pending_disposal", "batch_discarded", "warning_escalated", "overdue_return_violation", ]); const terminalStates = new Set(["consumed", "pending_disposal", "discarded", "warning"]); const eventName = String(event.event || ""); const state = String(event.state || "").toLowerCase(); return Boolean(event.started_at) && !event.ended_at && !terminalEvents.has(eventName) && !terminalStates.has(state); } function timestampMillis(value) { if (value instanceof Date) { return value.getTime(); } const parsed = Date.parse(String(value || "")); return Number.isFinite(parsed) ? parsed : Number.NaN; } function isNewerEventCandidate(next, existing) { if (next.eventTime !== null && existing.eventTime !== null && next.eventTime !== existing.eventTime) { return next.eventTime > existing.eventTime; } return next.order > existing.order; } export function normalizePolygon(value) { if (!Array.isArray(value)) { return []; } return value .filter((point) => Array.isArray(point) || (point && typeof point === "object")) .map((point) => { const x = Array.isArray(point) ? point[0] : point.x; const y = Array.isArray(point) ? point[1] : point.y; return {x: round(clamp(Number(x))), y: round(clamp(Number(y)))}; }) .filter((point) => Number.isFinite(point.x) && Number.isFinite(point.y)); } function serializePolygon(points) { return normalizePolygon(points).map((point) => [point.x, point.y]); } function normalizeZoneIds(value) { if (!Array.isArray(value)) { return []; } return value.map((id) => String(id).trim()).filter(Boolean); } function deriveLegacySourceIds(layout, configuredIds, zones, count) { const configuredLegacyIds = configuredIds.filter((id) => !isNumericId(id)); if (configuredLegacyIds.length) { return configuredLegacyIds; } if (!configuredIds.length) { const rowColIds = rowColumnZoneIds(layout).slice(0, count); if (rowColIds.length) { return rowColIds; } } return zones.map((zone) => String(zone.id || "")).filter((id) => id && !isNumericId(id)); } function rowColumnZoneIds(layout) { const rows = Number(layout.rows); const cols = Number(layout.cols); if (!Number.isFinite(rows) || !Number.isFinite(cols) || rows <= 0 || cols <= 0) { return []; } const ids = []; for (let row = 1; row <= Math.trunc(rows); row += 1) { for (let col = 1; col <= Math.trunc(cols); col += 1) { ids.push(`r${row}c${col}`); } } return ids; } function isNumericId(id) { return /^\d+$/.test(String(id)); } function deriveEventZoneIndex(event) { const explicit = Number(event.zone_index); if (Number.isInteger(explicit) && explicit > 0) { return explicit; } const zoneId = String(event.zone_id || ""); if (isNumericId(zoneId)) { return Number(zoneId); } return null; } function defaultSeverity(eventName) { if (eventName === "time_alarm") { return "alarm"; } if (eventName === "warning_escalated" || eventName.endsWith("_violation")) { return "warning"; } return "info"; } function clamp(value) { return Math.min(1, Math.max(0, value)); } function round(value) { return Math.round(value * 1000000) / 1000000; }