diff --git a/remicon_cost_app.html b/remicon_cost_app.html index 202cffe..667217d 100644 --- a/remicon_cost_app.html +++ b/remicon_cost_app.html @@ -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 += `