reliability semi-fix
This commit is contained in:
44
components/recap/RecapBanners.tsx
Normal file
44
components/recap/RecapBanners.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
|
||||
type Props = {
|
||||
moldChangeStartMs: number | null;
|
||||
offlineForMin: number | null;
|
||||
ongoingStopMin: number | null;
|
||||
};
|
||||
|
||||
function toInt(value: number | null | undefined) {
|
||||
if (value == null || Number.isNaN(value)) return 0;
|
||||
return Math.max(0, Math.round(value));
|
||||
}
|
||||
|
||||
export default function RecapBanners({ moldChangeStartMs, offlineForMin, ongoingStopMin }: Props) {
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
const moldStartLabel = moldChangeStartMs
|
||||
? new Date(moldChangeStartMs).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" })
|
||||
: "--:--";
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{moldChangeStartMs ? (
|
||||
<div className="rounded-xl border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-sm text-amber-200">
|
||||
{t("recap.banner.moldChange", { time: moldStartLabel })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{offlineForMin != null && offlineForMin > 10 ? (
|
||||
<div className="rounded-xl border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||
{t("recap.banner.offline", { min: toInt(offlineForMin) })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{ongoingStopMin != null && ongoingStopMin > 0 ? (
|
||||
<div className="rounded-xl border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||
{t("recap.banner.ongoingStop", { min: toInt(ongoingStopMin) })}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,54 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { Bar, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis, BarChart } from "recharts";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
|
||||
type Row = {
|
||||
reasonLabel: string;
|
||||
minutes: number;
|
||||
count: number;
|
||||
};
|
||||
import type { RecapDowntimeTopRow } from "@/lib/recap/types";
|
||||
|
||||
type Props = {
|
||||
rows: Row[];
|
||||
rows: RecapDowntimeTopRow[];
|
||||
};
|
||||
|
||||
export default function RecapDowntimeTop({ rows }: Props) {
|
||||
const { t } = useI18n();
|
||||
const data = rows.slice(0, 3).map((row) => ({ ...row, label: row.reasonLabel.slice(0, 20) }));
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||
<div className="mb-3 text-sm font-semibold text-white">{t("recap.downtime.title")}</div>
|
||||
{data.length === 0 ? (
|
||||
<div className="mb-3 text-sm font-semibold text-white">{t("recap.downtime.top")}</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-sm text-zinc-400">{t("recap.empty.production")}</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="h-[170px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.08)" />
|
||||
<XAxis dataKey="label" tick={{ fill: "#a1a1aa", fontSize: 11 }} />
|
||||
<YAxis tick={{ fill: "#a1a1aa", fontSize: 11 }} />
|
||||
<Tooltip
|
||||
contentStyle={{ background: "rgba(0,0,0,0.85)", border: "1px solid rgba(255,255,255,0.12)" }}
|
||||
labelStyle={{ color: "#e4e4e7" }}
|
||||
/>
|
||||
<Bar dataKey="minutes" fill="#34d399" radius={[6, 6, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{data.map((row) => (
|
||||
<div key={row.reasonLabel} className="flex items-center justify-between text-xs text-zinc-300">
|
||||
<span className="truncate">{row.reasonLabel}</span>
|
||||
<span>
|
||||
{row.minutes.toFixed(1)} min · {row.count}
|
||||
</span>
|
||||
<div className="space-y-3">
|
||||
{rows.slice(0, 3).map((row) => (
|
||||
<div key={row.reasonLabel} className="rounded-xl border border-white/10 bg-black/20 p-3">
|
||||
<div className="text-sm font-medium text-white">{row.reasonLabel}</div>
|
||||
<div className="mt-1 text-xs text-zinc-300">
|
||||
{row.minutes.toFixed(1)} min · {row.percent.toFixed(1)}%
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
83
components/recap/RecapFullTimeline.tsx
Normal file
83
components/recap/RecapFullTimeline.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import type { RecapTimelineSegment } from "@/lib/recap/types";
|
||||
import {
|
||||
computeWidths,
|
||||
formatDuration,
|
||||
formatTime,
|
||||
LABEL_MIN_WIDTH_PCT,
|
||||
normalizeTimelineSegments,
|
||||
TIMELINE_COLORS,
|
||||
} from "@/components/recap/timelineRender";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
|
||||
type Props = {
|
||||
rangeStart: string;
|
||||
rangeEnd: string;
|
||||
segments: RecapTimelineSegment[];
|
||||
locale: string;
|
||||
hasData?: boolean;
|
||||
};
|
||||
|
||||
const MIN_SEGMENT_PCT = 1.5;
|
||||
|
||||
export default function RecapFullTimeline({ rangeStart, rangeEnd, segments, locale, hasData = true }: Props) {
|
||||
const { t } = useI18n();
|
||||
const startMs = new Date(rangeStart).getTime();
|
||||
const endMs = new Date(rangeEnd).getTime();
|
||||
const totalMs = Math.max(1, endMs - startMs);
|
||||
|
||||
const normalized = normalizeTimelineSegments(segments, startMs, endMs);
|
||||
const widths = computeWidths(normalized, totalMs, MIN_SEGMENT_PCT);
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||
<div className="mb-3 text-sm font-semibold text-white">{t("recap.timeline.title")}</div>
|
||||
{!hasData ? (
|
||||
<div className="rounded-xl border border-dashed border-white/10 bg-black/20 p-4 text-sm text-zinc-400">
|
||||
{t("recap.timeline.noData")}
|
||||
</div>
|
||||
) : null}
|
||||
{hasData ? (
|
||||
<div className="overflow-x-auto">
|
||||
<div className="min-w-[560px]">
|
||||
<div className="flex h-14 w-full overflow-hidden rounded-xl">
|
||||
{normalized.map((segment, index) => {
|
||||
const widthPct = widths[index] ?? 0;
|
||||
const typeLabel =
|
||||
segment.type === "production"
|
||||
? t("recap.timeline.type.production")
|
||||
: segment.type === "mold-change"
|
||||
? t("recap.timeline.type.moldChange")
|
||||
: segment.type === "macrostop"
|
||||
? t("recap.timeline.type.macrostop")
|
||||
: segment.type === "microstop" || segment.type === "slow-cycle"
|
||||
? t("recap.timeline.type.microstop")
|
||||
: t("recap.timeline.type.idle");
|
||||
const title = `${typeLabel} · ${formatTime(segment.startMs, locale)}-${formatTime(
|
||||
segment.endMs,
|
||||
locale
|
||||
)} · ${formatDuration(segment.startMs, segment.endMs)}${segment.label ? ` · ${segment.label}` : ""}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${segment.type}:${segment.startMs}:${segment.endMs}:${segment.label}`}
|
||||
className={`flex h-full shrink-0 items-center justify-center truncate px-2 text-xs font-semibold ${
|
||||
TIMELINE_COLORS[segment.type]
|
||||
} ${index === 0 ? "rounded-l-xl" : ""} ${
|
||||
index === normalized.length - 1 ? "rounded-r-xl" : ""
|
||||
}`}
|
||||
style={{ width: `${Math.max(0, widthPct)}%` }}
|
||||
title={title}
|
||||
>
|
||||
{widthPct > LABEL_MIN_WIDTH_PCT ? segment.label : ""}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,23 +9,26 @@ type Props = {
|
||||
scrapParts: number;
|
||||
};
|
||||
|
||||
function fmtPct(v: number | null) {
|
||||
if (v == null || Number.isNaN(v)) return "--";
|
||||
return `${v.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
export default function RecapKpiRow({ oeeAvg, goodParts, totalStops, scrapParts }: Props) {
|
||||
const { t } = useI18n();
|
||||
|
||||
const items = [
|
||||
{ label: t("recap.kpi.oee"), value: fmtPct(oeeAvg), valueClass: "text-emerald-400" },
|
||||
{ label: t("recap.kpi.good"), value: String(goodParts), valueClass: "text-white" },
|
||||
{ label: t("recap.kpi.stops"), value: String(totalStops), valueClass: totalStops > 0 ? "text-amber-400" : "text-white" },
|
||||
{ label: t("recap.kpi.scrap"), value: String(scrapParts), valueClass: scrapParts > 0 ? "text-red-400" : "text-white" },
|
||||
{ label: t("recap.kpi.stops"), value: String(totalStops), valueClass: totalStops > 0 ? "text-amber-300" : "text-white" },
|
||||
{ label: t("recap.kpi.scrap"), value: String(scrapParts), valueClass: scrapParts > 0 ? "text-red-300" : "text-white" },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||
<div className={`text-2xl font-semibold ${oeeAvg == null || Number.isNaN(oeeAvg) ? "text-zinc-400" : "text-emerald-300"}`}>
|
||||
{oeeAvg == null || Number.isNaN(oeeAvg) ? "—" : `${oeeAvg.toFixed(1)}%`}
|
||||
</div>
|
||||
<div className="mt-1 text-xs uppercase tracking-wide text-zinc-400">{t("recap.kpi.oee")}</div>
|
||||
{oeeAvg == null || Number.isNaN(oeeAvg) ? (
|
||||
<div className="mt-1 text-xs text-zinc-500">{t("recap.kpi.noData")}</div>
|
||||
) : null}
|
||||
</div>
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||
<div className={`text-2xl font-semibold ${item.valueClass}`}>{item.value}</div>
|
||||
|
||||
154
components/recap/RecapMachineCard.tsx
Normal file
154
components/recap/RecapMachineCard.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import type { RecapSummaryMachine, RecapTimelineResponse } from "@/lib/recap/types";
|
||||
import RecapMiniTimeline from "@/components/recap/RecapMiniTimeline";
|
||||
|
||||
type Props = {
|
||||
machine: RecapSummaryMachine;
|
||||
rangeStart: string;
|
||||
rangeEnd: string;
|
||||
};
|
||||
|
||||
const STATUS_DOT: Record<RecapSummaryMachine["status"], string> = {
|
||||
running: "bg-emerald-400",
|
||||
"mold-change": "bg-amber-400",
|
||||
stopped: "bg-red-500",
|
||||
offline: "bg-zinc-500",
|
||||
};
|
||||
|
||||
function statusLabel(status: RecapSummaryMachine["status"], t: (key: string) => string) {
|
||||
if (status === "running") return t("recap.status.running");
|
||||
if (status === "mold-change") return t("recap.status.moldChange");
|
||||
if (status === "stopped") return t("recap.status.stopped");
|
||||
return t("recap.status.offline");
|
||||
}
|
||||
|
||||
function toInt(value: number | null | undefined) {
|
||||
if (value == null || Number.isNaN(value)) return 0;
|
||||
return Math.max(0, Math.round(value));
|
||||
}
|
||||
|
||||
export default function RecapMachineCard({ machine, rangeStart, rangeEnd }: Props) {
|
||||
const { t, locale } = useI18n();
|
||||
const [timeline, setTimeline] = useState<RecapTimelineResponse | null>(null);
|
||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
||||
|
||||
const zeroActivity = machine.goodParts === 0 && machine.scrap === 0 && machine.stopsCount === 0;
|
||||
const primaryMetric = machine.oee == null ? "—" : `${machine.oee.toFixed(1)}%`;
|
||||
const timelineSegments = timeline?.segments ?? machine.miniTimeline;
|
||||
const timelineStart = timeline?.range.start ?? rangeStart;
|
||||
const timelineEnd = timeline?.range.end ?? rangeEnd;
|
||||
const hasTimelineData = timeline?.hasData ?? timelineSegments.length > 0;
|
||||
const staleHeartbeat = machine.lastSeenMs == null ? true : nowMs - machine.lastSeenMs > 5 * 60 * 1000;
|
||||
|
||||
const lastSeenLabel =
|
||||
machine.lastActivityMin == null
|
||||
? t("common.never")
|
||||
: t("recap.card.lastActivity", { min: toInt(machine.lastActivityMin) });
|
||||
|
||||
const footerText = machine.activeWorkOrderId
|
||||
? t("recap.card.activeWorkOrder", { id: machine.activeWorkOrderId })
|
||||
: lastSeenLabel;
|
||||
|
||||
const moldMinutes = machine.moldChange?.active ? machine.moldChange.elapsedMin : null;
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => setNowMs(Date.now()), 60000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
|
||||
async function loadTimeline() {
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/recap/${machine.machineId}/timeline?range=24h&compact=1&maxSegments=30`,
|
||||
{ cache: "no-store" }
|
||||
);
|
||||
const json = await res.json().catch(() => null);
|
||||
if (!alive || !res.ok || !json) return;
|
||||
setTimeline(json as RecapTimelineResponse);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
void loadTimeline();
|
||||
const timer = window.setInterval(() => {
|
||||
void loadTimeline();
|
||||
}, 60000);
|
||||
|
||||
return () => {
|
||||
alive = false;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [machine.machineId]);
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/recap/${machine.machineId}`}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 p-4 transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-300/80"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-lg font-semibold text-white">{machine.name}</div>
|
||||
<div className="mt-1 truncate text-xs text-zinc-400">{machine.location || t("common.na")}</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-2 rounded-full border border-white/10 px-2 py-1 text-xs text-zinc-200">
|
||||
<span
|
||||
className={`inline-block h-2.5 w-2.5 rounded-full ${STATUS_DOT[machine.status]}`}
|
||||
aria-label={statusLabel(machine.status, t)}
|
||||
/>
|
||||
{statusLabel(machine.status, t)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-baseline gap-2">
|
||||
<div className={`text-3xl font-semibold ${machine.oee == null ? "text-zinc-400" : "text-white"}`}>{primaryMetric}</div>
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("recap.card.oee")}</div>
|
||||
</div>
|
||||
{machine.oee == null ? <div className="mt-1 text-xs text-zinc-500">{t("recap.kpi.noData")}</div> : null}
|
||||
|
||||
{zeroActivity ? <div className="mt-1 text-xs text-zinc-500">{t("recap.card.noProduction")}</div> : null}
|
||||
|
||||
<div className="mt-3 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-zinc-300">
|
||||
<span>{t("recap.card.good")}: {machine.goodParts}</span>
|
||||
<span>{t("recap.card.scrap")}: {machine.scrap}</span>
|
||||
<span>{t("recap.card.stops")}: {machine.stopsCount}</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<RecapMiniTimeline
|
||||
rangeStart={timelineStart}
|
||||
rangeEnd={timelineEnd}
|
||||
segments={timelineSegments}
|
||||
locale={locale}
|
||||
hasData={hasTimelineData}
|
||||
muted={zeroActivity}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{machine.moldChange?.active ? (
|
||||
<div className="mt-3 rounded-lg border border-amber-400/40 bg-amber-400/10 px-2 py-1.5 text-xs text-amber-200">
|
||||
{t("recap.card.moldChangeActive", { min: toInt(moldMinutes) })}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{machine.offlineForMin != null && machine.offlineForMin > 10 ? (
|
||||
<div className="mt-2 rounded-lg border border-red-500/40 bg-red-500/10 px-2 py-1.5 text-xs text-red-200">
|
||||
{t("recap.banner.offline", { min: toInt(machine.offlineForMin) })}
|
||||
</div>
|
||||
) : null}
|
||||
{staleHeartbeat ? (
|
||||
<div className="mt-2 rounded-lg border border-amber-400/40 bg-amber-400/10 px-2 py-1.5 text-xs text-amber-200">
|
||||
{t("recap.card.desynced")}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-3 text-xs text-zinc-400">{footerText}</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,44 +1,32 @@
|
||||
"use client";
|
||||
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import type { RecapMachine } from "@/lib/recap/types";
|
||||
|
||||
type Props = {
|
||||
machine: RecapMachine | null;
|
||||
heartbeat: {
|
||||
lastSeenAt: string | null;
|
||||
uptimePct: number | null;
|
||||
connectionStatus: "online" | "offline";
|
||||
};
|
||||
};
|
||||
|
||||
export default function RecapMachineStatus({ machine }: Props) {
|
||||
export default function RecapMachineStatus({ heartbeat }: Props) {
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
if (!machine) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||
<div className="text-sm text-zinc-400">{t("recap.empty.production")}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isStopped = (machine.downtime.ongoingStopMin ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||
<div className="mb-3 text-sm font-semibold text-white">{t("recap.machine.title")}</div>
|
||||
<ul className="space-y-2 text-sm text-zinc-200">
|
||||
<li>
|
||||
<span className={isStopped ? "text-red-400" : "text-emerald-400"}>
|
||||
{isStopped ? t("recap.machine.stopped") : t("recap.machine.running")}
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<span className={machine.workOrders.moldChangeInProgress ? "text-amber-400" : "text-zinc-300"}>
|
||||
{t("recap.machine.mold")}: {machine.workOrders.moldChangeInProgress ? t("common.yes") : t("common.no")}
|
||||
<span className={heartbeat.connectionStatus === "online" ? "text-emerald-300" : "text-red-300"}>
|
||||
{heartbeat.connectionStatus === "online" ? t("recap.machine.online") : t("recap.machine.offline")}
|
||||
</span>
|
||||
</li>
|
||||
<li className="text-zinc-400">
|
||||
{t("recap.machine.lastHeartbeat")}: {machine.heartbeat.lastSeenAt ? new Date(machine.heartbeat.lastSeenAt).toLocaleString(locale) : "--"}
|
||||
{t("recap.machine.lastHeartbeat")}: {heartbeat.lastSeenAt ? new Date(heartbeat.lastSeenAt).toLocaleString(locale) : "--"}
|
||||
</li>
|
||||
<li className="text-zinc-400">
|
||||
{t("recap.machine.uptime")}: {machine.heartbeat.uptimePct == null ? "--" : `${machine.heartbeat.uptimePct.toFixed(1)}%`}
|
||||
{t("recap.machine.uptime")}: {heartbeat.uptimePct == null ? "--" : `${heartbeat.uptimePct.toFixed(1)}%`}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
82
components/recap/RecapMiniTimeline.tsx
Normal file
82
components/recap/RecapMiniTimeline.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import type { RecapTimelineSegment } from "@/lib/recap/types";
|
||||
import {
|
||||
computeWidths,
|
||||
formatDuration,
|
||||
formatTime,
|
||||
normalizeTimelineSegments,
|
||||
TIMELINE_COLORS,
|
||||
} from "@/components/recap/timelineRender";
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
|
||||
type Props = {
|
||||
rangeStart: string;
|
||||
rangeEnd: string;
|
||||
segments: RecapTimelineSegment[];
|
||||
locale: string;
|
||||
muted?: boolean;
|
||||
hasData?: boolean;
|
||||
};
|
||||
|
||||
const MIN_SEGMENT_PCT = 1.5;
|
||||
|
||||
export default function RecapMiniTimeline({
|
||||
rangeStart,
|
||||
rangeEnd,
|
||||
segments,
|
||||
locale,
|
||||
muted = false,
|
||||
hasData = true,
|
||||
}: Props) {
|
||||
const { t } = useI18n();
|
||||
const startMs = new Date(rangeStart).getTime();
|
||||
const endMs = new Date(rangeEnd).getTime();
|
||||
const totalMs = Math.max(1, endMs - startMs);
|
||||
|
||||
const normalized = normalizeTimelineSegments(segments, startMs, endMs);
|
||||
const widths = computeWidths(normalized, totalMs, MIN_SEGMENT_PCT);
|
||||
|
||||
if (!hasData) {
|
||||
return (
|
||||
<div className="flex h-5 w-full items-center justify-center rounded-md bg-zinc-800/70 text-[10px] text-zinc-400">
|
||||
{t("recap.timeline.noData")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!normalized.length) {
|
||||
return <div className="h-5 w-full rounded-md bg-zinc-700/70" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-5 w-full overflow-hidden rounded-md">
|
||||
{normalized.map((segment, index) => {
|
||||
const widthPct = widths[index] ?? 0;
|
||||
const typeLabel =
|
||||
segment.type === "production"
|
||||
? t("recap.timeline.type.production")
|
||||
: segment.type === "mold-change"
|
||||
? t("recap.timeline.type.moldChange")
|
||||
: segment.type === "macrostop"
|
||||
? t("recap.timeline.type.macrostop")
|
||||
: segment.type === "microstop" || segment.type === "slow-cycle"
|
||||
? t("recap.timeline.type.microstop")
|
||||
: t("recap.timeline.type.idle");
|
||||
const title = `${typeLabel} · ${formatTime(segment.startMs, locale)}-${formatTime(segment.endMs, locale)} · ${formatDuration(segment.startMs, segment.endMs)}${segment.label ? ` · ${segment.label}` : ""}`;
|
||||
const color = muted ? "bg-zinc-700 text-zinc-300" : TIMELINE_COLORS[segment.type];
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${segment.type}:${segment.startMs}:${segment.endMs}:${segment.label}`}
|
||||
className={`h-full shrink-0 ${color} ${index === 0 ? "rounded-l-md" : ""} ${
|
||||
index === normalized.length - 1 ? "rounded-r-md" : ""
|
||||
}`}
|
||||
style={{ width: `${Math.max(0, widthPct)}%` }}
|
||||
title={title}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,34 +12,37 @@ export default function RecapProductionBySku({ rows }: Props) {
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||
<div className="mb-3 text-sm font-semibold text-white">{t("recap.production.title")}</div>
|
||||
<div className="mb-3 text-sm font-semibold text-white">{t("recap.production.bySku")}</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="text-sm text-zinc-400">{t("recap.empty.production")}</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="grid grid-cols-6 gap-2 border-b border-white/10 pb-2 text-xs uppercase tracking-wide text-zinc-400">
|
||||
<div>Maquina</div>
|
||||
<div>SKU</div>
|
||||
<div>{t("recap.production.good")}</div>
|
||||
<div>{t("recap.production.scrap")}</div>
|
||||
<div>{t("recap.production.target")}</div>
|
||||
<div>{t("recap.production.progress")}</div>
|
||||
</div>
|
||||
{rows.slice(0, 8).map((row) => {
|
||||
const pct = row.progressPct == null ? "--" : `${Math.round(row.progressPct)}%`;
|
||||
return (
|
||||
<div key={`${row.machineName}:${row.sku}`} className="grid grid-cols-6 gap-2 text-sm text-zinc-200">
|
||||
<div className="truncate text-zinc-400">{row.machineName}</div>
|
||||
<div className="truncate">{row.sku}</div>
|
||||
<div>{row.good}</div>
|
||||
<div className={row.scrap > 0 ? "text-red-400" : "text-zinc-200"}>{row.scrap}</div>
|
||||
<div>{row.target ?? "--"}</div>
|
||||
<div>
|
||||
<span className="text-emerald-400">{pct}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full text-sm text-zinc-200">
|
||||
<thead>
|
||||
<tr className="border-b border-white/10 text-left text-xs uppercase tracking-wide text-zinc-400">
|
||||
<th className="py-2 pr-3">{t("recap.production.sku")}</th>
|
||||
<th className="py-2 pr-3">{t("recap.production.good")}</th>
|
||||
<th className="py-2 pr-3">{t("recap.production.scrap")}</th>
|
||||
<th className="py-2 pr-3">{t("recap.production.target")}</th>
|
||||
<th className="py-2">{t("recap.production.progress")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.slice(0, 10).map((row) => {
|
||||
const progress = row.progressPct == null ? "--" : `${Math.round(row.progressPct)}%`;
|
||||
return (
|
||||
<tr key={`${row.sku}:${row.machineName}`} className="border-b border-white/5">
|
||||
<td className="py-2 pr-3">{row.sku}</td>
|
||||
<td className="py-2 pr-3">{row.good}</td>
|
||||
<td className={`py-2 pr-3 ${row.scrap > 0 ? "text-red-300" : ""}`}>{row.scrap}</td>
|
||||
<td className="py-2 pr-3">{row.target ?? "--"}</td>
|
||||
<td className="py-2 text-emerald-300">{progress}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,13 +10,15 @@ type Props = {
|
||||
};
|
||||
|
||||
const COLORS: Record<RecapTimelineSegment["type"], string> = {
|
||||
production: "bg-emerald-500 text-emerald-50",
|
||||
"mold-change": "bg-blue-400 text-blue-950",
|
||||
macrostop: "bg-red-500 text-red-50",
|
||||
microstop: "bg-orange-500 text-orange-50",
|
||||
"slow-cycle": "bg-amber-500 text-amber-950",
|
||||
idle: "bg-zinc-600 text-zinc-100",
|
||||
production: "bg-emerald-500 text-black",
|
||||
"mold-change": "bg-sky-400 text-black",
|
||||
macrostop: "bg-red-500 text-white",
|
||||
microstop: "bg-orange-500 text-black",
|
||||
"slow-cycle": "bg-amber-500 text-black",
|
||||
idle: "bg-zinc-600 text-zinc-300",
|
||||
};
|
||||
const MIN_SEGMENT_PCT = 1.5;
|
||||
const LABEL_MIN_PCT = 5;
|
||||
|
||||
function fmtTime(valueMs: number, locale: string) {
|
||||
return new Date(valueMs).toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" });
|
||||
@@ -30,54 +32,141 @@ function fmtDuration(startMs: number, endMs: number) {
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
function shouldMergeByType(type: RecapTimelineSegment["type"]) {
|
||||
return type === "macrostop" || type === "microstop" || type === "slow-cycle" || type === "idle";
|
||||
}
|
||||
|
||||
function normalizeForRender(segments: RecapTimelineSegment[], startMs: number, endMs: number) {
|
||||
const ordered = segments
|
||||
.map((segment) => ({
|
||||
...segment,
|
||||
startMs: Math.max(startMs, segment.startMs),
|
||||
endMs: Math.min(endMs, segment.endMs),
|
||||
}))
|
||||
.filter((segment) => segment.endMs > segment.startMs)
|
||||
.sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
|
||||
|
||||
const out: RecapTimelineSegment[] = [];
|
||||
let cursor = startMs;
|
||||
|
||||
for (const segment of ordered) {
|
||||
if (segment.startMs > cursor) {
|
||||
const prev = out[out.length - 1];
|
||||
if (prev) {
|
||||
prev.endMs = segment.startMs;
|
||||
} else {
|
||||
out.push({
|
||||
type: "idle",
|
||||
startMs: cursor,
|
||||
endMs: segment.startMs,
|
||||
durationSec: Math.max(0, Math.trunc((segment.startMs - cursor) / 1000)),
|
||||
label: "Idle",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedStart = Math.max(cursor, segment.startMs);
|
||||
const normalizedEnd = Math.min(endMs, segment.endMs);
|
||||
if (normalizedEnd <= normalizedStart) continue;
|
||||
|
||||
const normalizedSegment: RecapTimelineSegment = {
|
||||
...segment,
|
||||
startMs: normalizedStart,
|
||||
endMs: normalizedEnd,
|
||||
};
|
||||
const prev = out[out.length - 1];
|
||||
|
||||
if (
|
||||
prev &&
|
||||
prev.type === normalizedSegment.type &&
|
||||
shouldMergeByType(prev.type) &&
|
||||
prev.endMs === normalizedSegment.startMs
|
||||
) {
|
||||
prev.endMs = normalizedSegment.endMs;
|
||||
} else {
|
||||
out.push(normalizedSegment);
|
||||
}
|
||||
cursor = normalizedEnd;
|
||||
if (cursor >= endMs) break;
|
||||
}
|
||||
|
||||
if (cursor < endMs) {
|
||||
const prev = out[out.length - 1];
|
||||
if (prev) {
|
||||
prev.endMs = endMs;
|
||||
} else {
|
||||
out.push({
|
||||
type: "idle",
|
||||
startMs: cursor,
|
||||
endMs,
|
||||
durationSec: Math.max(0, Math.trunc((endMs - cursor) / 1000)),
|
||||
label: "Idle",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return out.filter((segment) => segment.endMs > segment.startMs);
|
||||
}
|
||||
|
||||
function computeWidths(segments: RecapTimelineSegment[], totalMs: number, minPct: number) {
|
||||
if (!segments.length) return [];
|
||||
const base = segments.map((segment) => ((segment.endMs - segment.startMs) / totalMs) * 100);
|
||||
const effectiveMin = Math.min(minPct, 100 / segments.length);
|
||||
let widths = base.map((pct) => Math.max(pct, effectiveMin));
|
||||
|
||||
const sum = widths.reduce((acc, value) => acc + value, 0);
|
||||
if (sum > 100) {
|
||||
const overflow = sum - 100;
|
||||
const slacks = widths.map((value) => Math.max(0, value - effectiveMin));
|
||||
const totalSlack = slacks.reduce((acc, value) => acc + value, 0);
|
||||
if (totalSlack > 0) {
|
||||
widths = widths.map((value, index) => value - (overflow * slacks[index]) / totalSlack);
|
||||
} else {
|
||||
const scale = 100 / sum;
|
||||
widths = widths.map((value) => value * scale);
|
||||
}
|
||||
} else if (sum < 100) {
|
||||
const deficit = 100 - sum;
|
||||
const totalBase = base.reduce((acc, value) => acc + (value > 0 ? value : 1), 0);
|
||||
widths = widths.map((value, index) => value + (deficit * (base[index] > 0 ? base[index] : 1)) / totalBase);
|
||||
}
|
||||
|
||||
const rounded = widths.map((value) => Number(value.toFixed(4)));
|
||||
const roundedSum = rounded.reduce((acc, value) => acc + value, 0);
|
||||
const delta = Number((100 - roundedSum).toFixed(4));
|
||||
if (rounded.length > 0) {
|
||||
rounded[rounded.length - 1] = Number(Math.max(0, rounded[rounded.length - 1] + delta).toFixed(4));
|
||||
}
|
||||
return rounded;
|
||||
}
|
||||
|
||||
export default function RecapTimeline({ rangeStart, rangeEnd, segments, locale }: Props) {
|
||||
const startMs = new Date(rangeStart).getTime();
|
||||
const endMs = new Date(rangeEnd).getTime();
|
||||
const totalMs = Math.max(1, endMs - startMs);
|
||||
|
||||
const bars: RecapTimelineSegment[] = [];
|
||||
const dots: Array<{ leftPct: number; segment: RecapTimelineSegment }> = [];
|
||||
|
||||
for (const segment of segments) {
|
||||
const widthPct = ((segment.endMs - segment.startMs) / totalMs) * 100;
|
||||
const leftPct = ((segment.startMs - startMs) / totalMs) * 100;
|
||||
if (widthPct < 1) {
|
||||
if (segment.type !== "idle" && leftPct > 0.5 && leftPct < 99.5) {
|
||||
dots.push({ leftPct, segment });
|
||||
}
|
||||
} else {
|
||||
bars.push(segment);
|
||||
}
|
||||
}
|
||||
const normalized = normalizeForRender(segments, startMs, endMs);
|
||||
const widths = computeWidths(normalized, totalMs, MIN_SEGMENT_PCT);
|
||||
|
||||
return (
|
||||
<div className="mb-4 rounded-2xl border border-white/10 bg-black/40 p-3">
|
||||
<div className="mb-2 text-xs uppercase tracking-wide text-zinc-400">Timeline 24h</div>
|
||||
<div className="relative">
|
||||
<div className="flex h-12 w-full overflow-hidden rounded-xl border border-white/10">
|
||||
{bars.map((segment) => {
|
||||
const widthPct = ((segment.endMs - segment.startMs) / totalMs) * 100;
|
||||
const title = `${segment.type} · ${fmtTime(segment.startMs, locale)}-${fmtTime(segment.endMs, locale)} · ${fmtDuration(segment.startMs, segment.endMs)}${segment.label ? ` · ${segment.label}` : ""}`;
|
||||
return (
|
||||
<div
|
||||
key={`${segment.type}:${segment.startMs}:${segment.endMs}:${segment.label}`}
|
||||
className={`flex items-center justify-center truncate px-1 text-xs font-medium ${COLORS[segment.type]}`}
|
||||
style={{ width: `${widthPct}%` }}
|
||||
title={title}
|
||||
>
|
||||
{widthPct >= 6 ? segment.label : ""}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{dots.map(({ leftPct, segment }) => (
|
||||
<div
|
||||
key={`dot:${segment.type}:${segment.startMs}:${segment.endMs}`}
|
||||
className={`absolute top-1/2 h-2 w-2 -translate-x-1/2 -translate-y-1/2 rounded-full border border-black/30 ${COLORS[segment.type].split(" ")[0]}`}
|
||||
style={{ left: `${Math.max(0.3, Math.min(99.7, leftPct))}%` }}
|
||||
title={`${segment.type} · ${fmtTime(segment.startMs, locale)}-${fmtTime(segment.endMs, locale)} · ${fmtDuration(segment.startMs, segment.endMs)}`}
|
||||
/>
|
||||
))}
|
||||
<div className="flex h-14 w-full overflow-hidden rounded-xl border border-white/10">
|
||||
{normalized.map((segment, index) => {
|
||||
const widthPct = widths[index] ?? 0;
|
||||
const title = `${segment.type} · ${fmtTime(segment.startMs, locale)}-${fmtTime(segment.endMs, locale)} · ${fmtDuration(segment.startMs, segment.endMs)}${segment.label ? ` · ${segment.label}` : ""}`;
|
||||
return (
|
||||
<div
|
||||
key={`${segment.type}:${segment.startMs}:${segment.endMs}:${segment.label}`}
|
||||
className={`flex h-full shrink-0 items-center justify-center truncate px-2 text-xs font-semibold ${COLORS[segment.type]} ${
|
||||
index === 0 ? "rounded-l-xl" : ""
|
||||
} ${index === normalized.length - 1 ? "rounded-r-xl" : ""}`}
|
||||
style={{ width: `${Math.max(0, widthPct)}%` }}
|
||||
title={title}
|
||||
>
|
||||
{widthPct > LABEL_MIN_PCT ? segment.label : ""}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
59
components/recap/RecapWorkOrders.tsx
Normal file
59
components/recap/RecapWorkOrders.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useI18n } from "@/lib/i18n/useI18n";
|
||||
import type { RecapWorkOrders as RecapWorkOrdersType } from "@/lib/recap/types";
|
||||
|
||||
type Props = {
|
||||
workOrders: RecapWorkOrdersType;
|
||||
};
|
||||
|
||||
export default function RecapWorkOrders({ workOrders }: Props) {
|
||||
const { t, locale } = useI18n();
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-white/10 bg-black/40 p-4">
|
||||
<div className="mb-3 text-sm font-semibold text-white">{t("recap.workOrders.title")}</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("recap.workOrders.completed")}</div>
|
||||
{workOrders.completed.length === 0 ? (
|
||||
<div className="mt-2 text-sm text-zinc-400">{t("recap.workOrders.none")}</div>
|
||||
) : (
|
||||
<div className="mt-2 space-y-2">
|
||||
{workOrders.completed.slice(0, 6).map((row) => (
|
||||
<div key={row.id} className="rounded-lg border border-white/10 bg-black/20 p-2 text-xs text-zinc-300">
|
||||
<div className="font-medium text-white">{row.id}</div>
|
||||
<div>{t("recap.workOrders.sku")}: {row.sku || "--"}</div>
|
||||
<div>{t("recap.workOrders.goodParts")}: {row.goodParts}</div>
|
||||
<div>{t("recap.workOrders.duration")}: {row.durationHrs.toFixed(2)}h</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-wide text-zinc-400">{t("recap.workOrders.active")}</div>
|
||||
{!workOrders.active ? (
|
||||
<div className="mt-2 text-sm text-zinc-400">{t("recap.workOrders.none")}</div>
|
||||
) : (
|
||||
<div className="mt-2 rounded-lg border border-white/10 bg-black/20 p-3 text-sm text-zinc-200">
|
||||
<div className="font-medium text-white">{workOrders.active.id}</div>
|
||||
<div className="text-zinc-400">{t("recap.workOrders.sku")}: {workOrders.active.sku || "--"}</div>
|
||||
<div className="mt-2 h-2 rounded-full bg-white/10">
|
||||
<div
|
||||
className="h-2 rounded-full bg-emerald-400"
|
||||
style={{ width: `${Math.max(0, Math.min(100, workOrders.active.progressPct ?? 0))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-zinc-400">
|
||||
{t("recap.workOrders.startedAt")}: {workOrders.active.startedAt ? new Date(workOrders.active.startedAt).toLocaleString(locale) : "--"}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
components/recap/timelineRender.ts
Normal file
150
components/recap/timelineRender.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { RecapTimelineSegment } from "@/lib/recap/types";
|
||||
|
||||
export const TIMELINE_COLORS: Record<RecapTimelineSegment["type"], string> = {
|
||||
production: "bg-emerald-500 text-black",
|
||||
"mold-change": "bg-sky-400 text-black",
|
||||
macrostop: "bg-red-500 text-white",
|
||||
microstop: "bg-orange-500 text-black",
|
||||
"slow-cycle": "bg-orange-500 text-black",
|
||||
idle: "bg-zinc-700 text-zinc-300",
|
||||
};
|
||||
|
||||
export const LABEL_MIN_WIDTH_PCT = 5;
|
||||
|
||||
export function formatTime(valueMs: number, locale: string) {
|
||||
return new Date(valueMs).toLocaleTimeString(locale, {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function formatDuration(startMs: number, endMs: number) {
|
||||
const totalMin = Math.max(0, Math.round((endMs - startMs) / 60000));
|
||||
if (totalMin < 60) return `${totalMin}m`;
|
||||
const h = Math.floor(totalMin / 60);
|
||||
const m = totalMin % 60;
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
|
||||
export function normalizeTimelineSegments(
|
||||
segments: RecapTimelineSegment[],
|
||||
rangeStartMs: number,
|
||||
rangeEndMs: number
|
||||
) {
|
||||
const ordered = [...segments]
|
||||
.map((segment) => ({
|
||||
...segment,
|
||||
startMs: Math.max(rangeStartMs, segment.startMs),
|
||||
endMs: Math.min(rangeEndMs, segment.endMs),
|
||||
}))
|
||||
.filter((segment) => segment.endMs > segment.startMs)
|
||||
.sort((a, b) => a.startMs - b.startMs || a.endMs - b.endMs);
|
||||
|
||||
const out: RecapTimelineSegment[] = [];
|
||||
let cursor = rangeStartMs;
|
||||
|
||||
for (const segment of ordered) {
|
||||
if (segment.startMs > cursor) {
|
||||
out.push({
|
||||
type: "idle",
|
||||
startMs: cursor,
|
||||
endMs: segment.startMs,
|
||||
durationSec: Math.max(0, Math.trunc((segment.startMs - cursor) / 1000)),
|
||||
label: "Idle",
|
||||
});
|
||||
}
|
||||
|
||||
const startMs = Math.max(cursor, segment.startMs);
|
||||
const endMs = Math.min(rangeEndMs, segment.endMs);
|
||||
if (endMs <= startMs) continue;
|
||||
|
||||
if (segment.type === "production") {
|
||||
out.push({
|
||||
type: "production",
|
||||
startMs,
|
||||
endMs,
|
||||
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
|
||||
workOrderId: segment.workOrderId,
|
||||
sku: segment.sku,
|
||||
label: segment.label,
|
||||
});
|
||||
} else if (segment.type === "mold-change") {
|
||||
out.push({
|
||||
type: "mold-change",
|
||||
startMs,
|
||||
endMs,
|
||||
fromMoldId: segment.fromMoldId,
|
||||
toMoldId: segment.toMoldId,
|
||||
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
|
||||
label: segment.label,
|
||||
});
|
||||
} else if (segment.type === "macrostop" || segment.type === "microstop" || segment.type === "slow-cycle") {
|
||||
out.push({
|
||||
type: segment.type === "slow-cycle" ? "microstop" : segment.type,
|
||||
startMs,
|
||||
endMs,
|
||||
reason: segment.reason,
|
||||
reasonLabel: segment.reasonLabel ?? segment.reason,
|
||||
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
|
||||
label: segment.label,
|
||||
});
|
||||
} else {
|
||||
out.push({
|
||||
type: "idle",
|
||||
startMs,
|
||||
endMs,
|
||||
durationSec: Math.max(0, Math.trunc((endMs - startMs) / 1000)),
|
||||
label: segment.label,
|
||||
});
|
||||
}
|
||||
|
||||
cursor = endMs;
|
||||
if (cursor >= rangeEndMs) break;
|
||||
}
|
||||
|
||||
if (cursor < rangeEndMs) {
|
||||
out.push({
|
||||
type: "idle",
|
||||
startMs: cursor,
|
||||
endMs: rangeEndMs,
|
||||
durationSec: Math.max(0, Math.trunc((rangeEndMs - cursor) / 1000)),
|
||||
label: "Idle",
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function computeWidths(segments: RecapTimelineSegment[], totalMs: number, minPct: number) {
|
||||
if (!segments.length) return [];
|
||||
const base = segments.map((segment) => ((segment.endMs - segment.startMs) / totalMs) * 100);
|
||||
const effectiveMin = Math.min(minPct, 100 / segments.length);
|
||||
let widths = base.map((pct) => Math.max(pct, effectiveMin));
|
||||
|
||||
const sum = widths.reduce((acc, value) => acc + value, 0);
|
||||
if (sum > 100) {
|
||||
const overflow = sum - 100;
|
||||
const slacks = widths.map((value) => Math.max(0, value - effectiveMin));
|
||||
const totalSlack = slacks.reduce((acc, value) => acc + value, 0);
|
||||
if (totalSlack > 0) {
|
||||
widths = widths.map((value, index) => value - (overflow * slacks[index]) / totalSlack);
|
||||
} else {
|
||||
const scale = 100 / sum;
|
||||
widths = widths.map((value) => value * scale);
|
||||
}
|
||||
} else if (sum < 100) {
|
||||
const deficit = 100 - sum;
|
||||
const totalBase = base.reduce((acc, value) => acc + (value > 0 ? value : 1), 0);
|
||||
widths = widths.map(
|
||||
(value, index) => value + (deficit * (base[index] > 0 ? base[index] : 1)) / totalBase
|
||||
);
|
||||
}
|
||||
|
||||
const rounded = widths.map((value) => Number(value.toFixed(4)));
|
||||
const roundedSum = rounded.reduce((acc, value) => acc + value, 0);
|
||||
const delta = Number((100 - roundedSum).toFixed(4));
|
||||
if (rounded.length > 0) {
|
||||
rounded[rounded.length - 1] = Number(Math.max(0, rounded[rounded.length - 1] + delta).toFixed(4));
|
||||
}
|
||||
return rounded;
|
||||
}
|
||||
Reference in New Issue
Block a user