Files
cold_display_guard/web/src/zone-state.js

503 lines
15 KiB
JavaScript

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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[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;
}