Update remicon_cost_app.html with latest changes including chart tooltips and data revision logic

This commit is contained in:
2026-03-03 14:18:01 +09:00
parent 35ed87a49b
commit da29236520

View File

@@ -192,6 +192,20 @@
} }
.mix-compare-body { padding: 14px; max-height: 76vh; overflow: auto; } .mix-compare-body { padding: 14px; max-height: 76vh; overflow: auto; }
#mixCompareChart { width: 100%; height: 300px; display: block; } #mixCompareChart { width: 100%; height: 300px; display: block; }
.chart-tooltip {
position: fixed;
z-index: 9999;
pointer-events: none;
background: rgba(15, 23, 42, 0.95);
color: #fff;
font-size: 12px;
line-height: 1.35;
padding: 6px 8px;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.22);
white-space: nowrap;
display: none;
}
.mini-table { border-collapse: collapse; font-size: 12px; } .mini-table { border-collapse: collapse; font-size: 12px; }
.mini-table td { border: none; padding: 2px 6px; white-space: nowrap; text-align: center; } .mini-table td { border: none; padding: 2px 6px; white-space: nowrap; text-align: center; }
.combined-wrap { display: flex; align-items: flex-start; gap: 10px; } .combined-wrap { display: flex; align-items: flex-start; gap: 10px; }
@@ -267,7 +281,7 @@
.modal.open { display: flex; } .modal.open { display: flex; }
.modal-card { width: min(920px, 100%); background: #fff; border-radius: 14px; overflow: hidden; border: 1px solid var(--line); } .modal-card { width: min(920px, 100%); background: #fff; border-radius: 14px; overflow: hidden; border: 1px solid var(--line); }
.modal-head { background: #0f172a; color: #fff; padding: 12px 14px; display: flex; justify-content: space-between; align-items: center; } .modal-head { background: #0f172YT; color: #fff; padding: 12px 14px; display: flex; justify-content: space-between; align-items: center; }
.modal-body { padding: 14px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; } .modal-body { padding: 14px; display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
.modal-foot { padding: 12px 14px; border-top: 1px solid var(--line); display: flex; gap: 8px; justify-content: flex-end; } .modal-foot { padding: 12px 14px; border-top: 1px solid var(--line); display: flex; gap: 8px; justify-content: flex-end; }
.price-modal-body { padding: 14px; display: grid; grid-template-columns: 1fr; gap: 10px; } .price-modal-body { padding: 14px; display: grid; grid-template-columns: 1fr; gap: 10px; }
@@ -532,6 +546,8 @@
const MIX_DRAFT_KEY = "remicon_mix_draft_v1"; const MIX_DRAFT_KEY = "remicon_mix_draft_v1";
const PRICE_DRAFT_KEY = "remicon_price_draft_v1"; const PRICE_DRAFT_KEY = "remicon_price_draft_v1";
const CUSTOM_SPECS_KEY = "remicon_custom_specs_v1"; const CUSTOM_SPECS_KEY = "remicon_custom_specs_v1";
const EMBEDDED_DATA_REV_KEY = "remicon_embedded_data_rev_v1";
const EMBEDDED_DATA_REV = "2026-03-03-fixed-v1";
const PRESET_SPECS = [ const PRESET_SPECS = [
"25-27-600", "25-27-600",
"25-30-600", "25-30-600",
@@ -659,6 +675,8 @@
const statusRow = document.getElementById("statusRow"); const statusRow = document.getElementById("statusRow");
const compareBaseSelect = document.getElementById("compareBaseSelect"); const compareBaseSelect = document.getElementById("compareBaseSelect");
const compareTargetSelect = document.getElementById("compareTargetSelect"); const compareTargetSelect = document.getElementById("compareTargetSelect");
const chartHoverHandlers = new WeakMap();
let chartTooltipEl = null;
const PRICE_COMPARE_FIELDS = [ const PRICE_COMPARE_FIELDS = [
{ key: "cement", label: "시멘트 단가" }, { key: "cement", label: "시멘트 단가" },
@@ -940,6 +958,17 @@
} }
function seedDefaultDataIfEmpty() { function seedDefaultDataIfEmpty() {
const savedRev = localStorage.getItem(EMBEDDED_DATA_REV_KEY);
const mustApplyEmbedded = savedRev !== EMBEDDED_DATA_REV;
if (mustApplyEmbedded) {
saveJsonArray(PRICE_SETS_KEY, DEFAULT_PRICE_SETS);
saveJsonArray(MIX_HISTORY_KEY, DEFAULT_MIX_HISTORY);
localStorage.setItem(SELECTED_PRICE_SET_KEY, "ps_2026_02");
localStorage.setItem(CUSTOM_SPECS_KEY, JSON.stringify([]));
localStorage.setItem(EMBEDDED_DATA_REV_KEY, EMBEDDED_DATA_REV);
return;
}
const hasPriceSets = loadJsonArray(PRICE_SETS_KEY).length > 0; const hasPriceSets = loadJsonArray(PRICE_SETS_KEY).length > 0;
const hasMixHistory = loadJsonArray(MIX_HISTORY_KEY).length > 0; const hasMixHistory = loadJsonArray(MIX_HISTORY_KEY).length > 0;
if (!hasPriceSets) saveJsonArray(PRICE_SETS_KEY, DEFAULT_PRICE_SETS); if (!hasPriceSets) saveJsonArray(PRICE_SETS_KEY, DEFAULT_PRICE_SETS);
@@ -2016,6 +2045,65 @@
return Number.isInteger(rounded) ? rounded.toLocaleString("ko-KR") : rounded.toLocaleString("ko-KR", { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return Number.isInteger(rounded) ? rounded.toLocaleString("ko-KR") : rounded.toLocaleString("ko-KR", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
function fmtInt(n) {
return Math.round(toNum(n)).toLocaleString("ko-KR");
}
function getChartTooltip() {
if (chartTooltipEl && document.body.contains(chartTooltipEl)) return chartTooltipEl;
chartTooltipEl = document.createElement("div");
chartTooltipEl.className = "chart-tooltip";
document.body.appendChild(chartTooltipEl);
return chartTooltipEl;
}
function hideChartTooltip() {
const tip = getChartTooltip();
tip.style.display = "none";
}
function showChartTooltip(clientX, clientY, text) {
const tip = getChartTooltip();
tip.textContent = text;
tip.style.display = "block";
const offset = 12;
tip.style.left = `${clientX + offset}px`;
tip.style.top = `${clientY + offset}px`;
}
function bindCanvasHover(canvas, hitAreas) {
if (!canvas) return;
const prev = chartHoverHandlers.get(canvas);
if (prev) {
canvas.removeEventListener("mousemove", prev.move);
canvas.removeEventListener("mouseleave", prev.leave);
}
const move = (e) => {
const rect = canvas.getBoundingClientRect();
const x = e.clientX - rect.left;
const y = e.clientY - rect.top;
const hit = hitAreas.find((h) => x >= h.x && x <= h.x + h.w && y >= h.y && y <= h.y + h.h);
if (!hit) {
hideChartTooltip();
return;
}
showChartTooltip(e.clientX, e.clientY, `${fmtInt(hit.value)}`);
};
const leave = () => hideChartTooltip();
canvas.addEventListener("mousemove", move);
canvas.addEventListener("mouseleave", leave);
chartHoverHandlers.set(canvas, { move, leave });
}
function priceSetOrderValue(set) {
if (!set) return 0;
const y = toNum(set.year);
const m = toNum(set.month);
if (y > 0 && m > 0) return y * 100 + m;
return new Date(set.createdAt || 0).getTime();
}
function drawPriceCompareChart(canvas, base, target) { function drawPriceCompareChart(canvas, base, target) {
if (!canvas || !base || !target) return; if (!canvas || !base || !target) return;
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
@@ -2039,6 +2127,7 @@
const maxVal = Math.max(1, ...baseVals, ...targetVals); const maxVal = Math.max(1, ...baseVals, ...targetVals);
const groupW = plotW / keys.length; const groupW = plotW / keys.length;
const barW = Math.max(10, Math.min(22, groupW * 0.28)); const barW = Math.max(10, Math.min(22, groupW * 0.28));
const hitAreas = [];
ctx.strokeStyle = "#d1d5db"; ctx.strokeStyle = "#d1d5db";
ctx.lineWidth = 1; ctx.lineWidth = 1;
@@ -2054,7 +2143,7 @@
for (let i = 0; i <= 4; i += 1) { for (let i = 0; i <= 4; i += 1) {
const v = (maxVal * i) / 4; const v = (maxVal * i) / 4;
const y = padding.top + plotH - (plotH * i) / 4; const y = padding.top + plotH - (plotH * i) / 4;
ctx.fillText(fmt(v), padding.left - 6, y + 3); ctx.fillText(fmtInt(v), padding.left - 6, y + 3);
ctx.strokeStyle = "#eef2f7"; ctx.strokeStyle = "#eef2f7";
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(padding.left, y); ctx.moveTo(padding.left, y);
@@ -2075,6 +2164,8 @@
ctx.fillRect(baseX, baseY, barW, baseH); ctx.fillRect(baseX, baseY, barW, baseH);
ctx.fillStyle = "#0f766e"; ctx.fillStyle = "#0f766e";
ctx.fillRect(targetX, targetY, barW, targetH); ctx.fillRect(targetX, targetY, barW, targetH);
hitAreas.push({ x: baseX, y: baseY, w: barW, h: baseH, label: labels[i], value: baseVals[i], series: "기준" });
hitAreas.push({ x: targetX, y: targetY, w: barW, h: targetH, label: labels[i], value: targetVals[i], series: "비교" });
ctx.fillStyle = "#1f2937"; ctx.fillStyle = "#1f2937";
ctx.textAlign = "center"; ctx.textAlign = "center";
@@ -2090,6 +2181,7 @@
ctx.fillRect(padding.left + 54, cssHeight - 18, 10, 10); ctx.fillRect(padding.left + 54, cssHeight - 18, 10, 10);
ctx.fillStyle = "#1f2937"; ctx.fillStyle = "#1f2937";
ctx.fillText("비교", padding.left + 68, cssHeight - 9); ctx.fillText("비교", padding.left + 68, cssHeight - 9);
bindCanvasHover(canvas, hitAreas);
} }
function drawMixCostCompareChart(canvas, labels, datasets) { function drawMixCostCompareChart(canvas, labels, datasets) {
@@ -2115,6 +2207,7 @@
const slotW = Math.max(8, Math.min(20, groupW / (datasets.length + 1))); const slotW = Math.max(8, Math.min(20, groupW / (datasets.length + 1)));
const usedW = slotW * datasets.length; const usedW = slotW * datasets.length;
const startShift = usedW / 2; const startShift = usedW / 2;
const hitAreas = [];
ctx.strokeStyle = "#d1d5db"; ctx.strokeStyle = "#d1d5db";
ctx.beginPath(); ctx.beginPath();
@@ -2129,7 +2222,7 @@
for (let i = 0; i <= 4; i += 1) { for (let i = 0; i <= 4; i += 1) {
const v = (maxVal * i) / 4; const v = (maxVal * i) / 4;
const y = padding.top + plotH - (plotH * i) / 4; const y = padding.top + plotH - (plotH * i) / 4;
ctx.fillText(fmt(v), padding.left - 6, y + 3); ctx.fillText(fmtInt(v), padding.left - 6, y + 3);
ctx.strokeStyle = "#eef2f7"; ctx.strokeStyle = "#eef2f7";
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(padding.left, y); ctx.moveTo(padding.left, y);
@@ -2146,6 +2239,7 @@
const y = padding.top + plotH - h; const y = padding.top + plotH - h;
ctx.fillStyle = colors[di % colors.length]; ctx.fillStyle = colors[di % colors.length];
ctx.fillRect(x, y, slotW - 2, h); ctx.fillRect(x, y, slotW - 2, h);
hitAreas.push({ x, y, w: slotW - 2, h, label, value: v, series: ds.name });
}); });
ctx.fillStyle = "#1f2937"; ctx.fillStyle = "#1f2937";
ctx.textAlign = "center"; ctx.textAlign = "center";
@@ -2170,10 +2264,13 @@
ctx.fillText(name, lx + 14, ly); ctx.fillText(name, lx + 14, ly);
lx += 150; lx += 150;
}); });
bindCanvasHover(canvas, hitAreas);
} }
function openMixComparePopup(spec) { function openMixComparePopup(spec) {
const rows = mixHistory.filter((x) => x.spec === spec && selectedMixCompareIds.has(x.id)); const rows = mixHistory
.filter((x) => x.spec === spec && selectedMixCompareIds.has(x.id))
.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
if (rows.length === 0) { if (rows.length === 0) {
alert("비교할 배합을 먼저 체크하세요."); alert("비교할 배합을 먼저 체크하세요.");
return; return;
@@ -2206,12 +2303,21 @@
prepared.forEach((p) => { prepared.forEach((p) => {
html += `<th>${p.name}</th>`; html += `<th>${p.name}</th>`;
}); });
html += "<th>증.감비용</th>";
html += "</tr></thead><tbody>"; html += "</tr></thead><tbody>";
itemLabels.forEach((item, idx) => { itemLabels.forEach((item, idx) => {
html += `<tr><td>${item}</td>`; html += `<tr><td>${item}</td>`;
prepared.forEach((p) => { prepared.forEach((p) => {
html += `<td>${Math.round(toNum(p.values[idx])).toLocaleString("ko-KR")}</td>`; html += `<td>${Math.round(toNum(p.values[idx])).toLocaleString("ko-KR")}</td>`;
}); });
let diffCost = 0;
if (prepared.length >= 2) {
const firstVal = toNum(prepared[0].values[idx]);
const lastVal = toNum(prepared[prepared.length - 1].values[idx]);
diffCost = Math.round(lastVal - firstVal);
}
const diffText = `${diffCost >= 0 ? "+" : ""}${diffCost.toLocaleString("ko-KR")}`;
html += `<td>${diffText}</td>`;
html += "</tr>"; html += "</tr>";
}); });
html += "</tbody></table>"; html += "</tbody></table>";
@@ -2241,26 +2347,41 @@
return; return;
} }
priceSets.forEach((set, idx) => { priceSets.forEach((set) => {
const label = priceSetLabel(set); const label = priceSetLabel(set);
const o1 = document.createElement("option"); const o1 = document.createElement("option");
o1.value = set.id; o1.value = set.id;
o1.textContent = label; o1.textContent = label;
if (idx === 1) o1.selected = true;
compareBaseSelect.appendChild(o1); compareBaseSelect.appendChild(o1);
const o2 = document.createElement("option"); const o2 = document.createElement("option");
o2.value = set.id; o2.value = set.id;
o2.textContent = label; o2.textContent = label;
if (idx === 0) o2.selected = true;
compareTargetSelect.appendChild(o2); compareTargetSelect.appendChild(o2);
}); });
const byDateAsc = [...priceSets].sort((a, b) => priceSetOrderValue(a) - priceSetOrderValue(b));
compareBaseSelect.value = byDateAsc[0].id;
compareTargetSelect.value = byDateAsc[byDateAsc.length - 1].id;
renderPriceComparison(); renderPriceComparison();
} }
function normalizePriceCompareOrder() {
const base = priceSets.find((x) => x.id === compareBaseSelect.value);
const target = priceSets.find((x) => x.id === compareTargetSelect.value);
if (!base || !target || base.id === target.id) return;
const baseTs = priceSetOrderValue(base);
const targetTs = priceSetOrderValue(target);
if (baseTs > targetTs) {
compareBaseSelect.value = target.id;
compareTargetSelect.value = base.id;
}
}
function renderPriceComparison() { function renderPriceComparison() {
const area = document.getElementById("priceCompareArea"); const area = document.getElementById("priceCompareArea");
normalizePriceCompareOrder();
const base = priceSets.find((x) => x.id === compareBaseSelect.value); const base = priceSets.find((x) => x.id === compareBaseSelect.value);
const target = priceSets.find((x) => x.id === compareTargetSelect.value); const target = priceSets.find((x) => x.id === compareTargetSelect.value);