503 lines
15 KiB
JavaScript
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) => ({
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """,
|
|
"'": "'",
|
|
})[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;
|
|
}
|