feat: stabilize cold display runtime deployment
This commit is contained in:
502
web/src/zone-state.js
Normal file
502
web/src/zone-state.js
Normal file
@@ -0,0 +1,502 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user