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; }
|
.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);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user