Update remicon_cost_app.html with latest changes including chart tooltips and data revision logic
This commit is contained in:
@@ -192,6 +192,20 @@
|
||||
}
|
||||
.mix-compare-body { padding: 14px; max-height: 76vh; overflow: auto; }
|
||||
#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 td { border: none; padding: 2px 6px; white-space: nowrap; text-align: center; }
|
||||
.combined-wrap { display: flex; align-items: flex-start; gap: 10px; }
|
||||
@@ -267,7 +281,7 @@
|
||||
|
||||
.modal.open { display: flex; }
|
||||
.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-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; }
|
||||
@@ -532,6 +546,8 @@
|
||||
const MIX_DRAFT_KEY = "remicon_mix_draft_v1";
|
||||
const PRICE_DRAFT_KEY = "remicon_price_draft_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 = [
|
||||
"25-27-600",
|
||||
"25-30-600",
|
||||
@@ -659,6 +675,8 @@
|
||||
const statusRow = document.getElementById("statusRow");
|
||||
const compareBaseSelect = document.getElementById("compareBaseSelect");
|
||||
const compareTargetSelect = document.getElementById("compareTargetSelect");
|
||||
const chartHoverHandlers = new WeakMap();
|
||||
let chartTooltipEl = null;
|
||||
|
||||
const PRICE_COMPARE_FIELDS = [
|
||||
{ key: "cement", label: "시멘트 단가" },
|
||||
@@ -940,6 +958,17 @@
|
||||
}
|
||||
|
||||
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 hasMixHistory = loadJsonArray(MIX_HISTORY_KEY).length > 0;
|
||||
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 });
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!canvas || !base || !target) return;
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
@@ -2039,6 +2127,7 @@
|
||||
const maxVal = Math.max(1, ...baseVals, ...targetVals);
|
||||
const groupW = plotW / keys.length;
|
||||
const barW = Math.max(10, Math.min(22, groupW * 0.28));
|
||||
const hitAreas = [];
|
||||
|
||||
ctx.strokeStyle = "#d1d5db";
|
||||
ctx.lineWidth = 1;
|
||||
@@ -2054,7 +2143,7 @@
|
||||
for (let i = 0; i <= 4; i += 1) {
|
||||
const v = (maxVal * 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.beginPath();
|
||||
ctx.moveTo(padding.left, y);
|
||||
@@ -2075,6 +2164,8 @@
|
||||
ctx.fillRect(baseX, baseY, barW, baseH);
|
||||
ctx.fillStyle = "#0f766e";
|
||||
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.textAlign = "center";
|
||||
@@ -2090,6 +2181,7 @@
|
||||
ctx.fillRect(padding.left + 54, cssHeight - 18, 10, 10);
|
||||
ctx.fillStyle = "#1f2937";
|
||||
ctx.fillText("비교", padding.left + 68, cssHeight - 9);
|
||||
bindCanvasHover(canvas, hitAreas);
|
||||
}
|
||||
|
||||
function drawMixCostCompareChart(canvas, labels, datasets) {
|
||||
@@ -2115,6 +2207,7 @@
|
||||
const slotW = Math.max(8, Math.min(20, groupW / (datasets.length + 1)));
|
||||
const usedW = slotW * datasets.length;
|
||||
const startShift = usedW / 2;
|
||||
const hitAreas = [];
|
||||
|
||||
ctx.strokeStyle = "#d1d5db";
|
||||
ctx.beginPath();
|
||||
@@ -2129,7 +2222,7 @@
|
||||
for (let i = 0; i <= 4; i += 1) {
|
||||
const v = (maxVal * 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.beginPath();
|
||||
ctx.moveTo(padding.left, y);
|
||||
@@ -2146,6 +2239,7 @@
|
||||
const y = padding.top + plotH - h;
|
||||
ctx.fillStyle = colors[di % colors.length];
|
||||
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.textAlign = "center";
|
||||
@@ -2170,10 +2264,13 @@
|
||||
ctx.fillText(name, lx + 14, ly);
|
||||
lx += 150;
|
||||
});
|
||||
bindCanvasHover(canvas, hitAreas);
|
||||
}
|
||||
|
||||
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) {
|
||||
alert("비교할 배합을 먼저 체크하세요.");
|
||||
return;
|
||||
@@ -2206,12 +2303,21 @@
|
||||
prepared.forEach((p) => {
|
||||
html += `<th>${p.name}</th>`;
|
||||
});
|
||||
html += "<th>증.감비용</th>";
|
||||
html += "</tr></thead><tbody>";
|
||||
itemLabels.forEach((item, idx) => {
|
||||
html += `<tr><td>${item}</td>`;
|
||||
prepared.forEach((p) => {
|
||||
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 += "</tbody></table>";
|
||||
@@ -2241,26 +2347,41 @@
|
||||
return;
|
||||
}
|
||||
|
||||
priceSets.forEach((set, idx) => {
|
||||
priceSets.forEach((set) => {
|
||||
const label = priceSetLabel(set);
|
||||
const o1 = document.createElement("option");
|
||||
o1.value = set.id;
|
||||
o1.textContent = label;
|
||||
if (idx === 1) o1.selected = true;
|
||||
compareBaseSelect.appendChild(o1);
|
||||
|
||||
const o2 = document.createElement("option");
|
||||
o2.value = set.id;
|
||||
o2.textContent = label;
|
||||
if (idx === 0) o2.selected = true;
|
||||
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();
|
||||
}
|
||||
|
||||
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() {
|
||||
const area = document.getElementById("priceCompareArea");
|
||||
normalizePriceCompareOrder();
|
||||
const base = priceSets.find((x) => x.id === compareBaseSelect.value);
|
||||
const target = priceSets.find((x) => x.id === compareTargetSelect.value);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user