把一炉数百层,变成一张可追踪的工艺地图。
-选择炉次文件夹,Log Analysis 自动关联理论、光学和设备日志,标出需要复核的层。原始数据只读,分析全程留在本机。
+单炉次镀膜日志分析
+自动关联逐层理论、光学与设备数据,定位需复核层。全程本地运行,原始日志只读。
diff --git a/DESIGN.md b/DESIGN.md index 59382c6..f454b25 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -32,7 +32,7 @@ Use `Segoe UI`, `Microsoft YaHei`, then system sans-serif. Numeric log paths and - Desktop application: fixed top operation bar, left module navigation, sticky filter row, one active analysis module in the main work area. - The primary desktop viewport is 1440×900 with a 960×640 minimum. Below 900px, module navigation becomes horizontal. -- Public site: generous editorial sections with a split hero, a single synthetic workbench illustration, capability rows, privacy section and installation steps. +- Public site: generous editorial sections with a split hero, a synthetic workbench illustration, and a full-width five-module interactive capability guide. On desktop the guide uses a client view beside its explanation panel; at ≤1050px the explanation moves below and focuses/reveals after a hotspot is selected. Privacy and installation sections follow. - Use spacing in a 4px-derived rhythm. Dense controls may use 8–12px gaps; sections use 24–40px; public-site sections use 64px or more. ## Components @@ -59,6 +59,7 @@ Visible columns use Chinese field names from shared metadata. Unknown fields mus - Provide semantic dialog roles for chart focus and lock background scrolling while open. - Respect `prefers-reduced-motion`; motion is short and communicates state rather than decoration. - Maintain readable contrast and never communicate anomaly severity with color alone. +- The public guide renders synthetic chart data on `Canvas`; module navigation uses `tablist`/`tab` semantics with roving keyboard focus, arrow keys and Home/End, while metric switches expose `aria-pressed`. Hotspots are keyboard-focusable; on narrow layouts, selecting one focuses and reveals the explanation panel. ## Content rules diff --git a/site/demo.js b/site/demo.js new file mode 100644 index 0000000..a0a7a38 --- /dev/null +++ b/site/demo.js @@ -0,0 +1,276 @@ +(() => { + "use strict"; + + const $ = id => document.getElementById(id); + const moduleTabs = [...document.querySelectorAll("[data-guide-module]")]; + const modulePanels = [...document.querySelectorAll("[data-guide-panel]")]; + const hotspots = [...document.querySelectorAll("[data-guide-hotspot]")]; + const moduleFirstHotspot = { + overview: "overview-review", + theory: "theory-band", + optical: "optical-endpoint", + device: "device-metric", + anomaly: "anomaly-metric" + }; + + const details = { + "overview-review": ["总览", "重点复核层", "汇总达到重点复核阈值的层。", "完成分析后确定优先检查顺序。", "进入总览,选择重点复核层查看对应记录。", "统计偏离仅提供复核线索,不代表质量结论。"], + "overview-quality": ["总览", "数据质量", "汇总缺失文件、字段异常和时间逻辑问题。", "解读趋势和复核提示前确认数据可用性。", "展开数据质量记录,按错误、警告和提示逐项核验。", "数据质量问题独立于统计复核提示。"], + "theory-band": ["理论趋势", "材料与监控方式", "按层显示 H/L 材料及 OMS/Timer 监控方式。", "核对材料切换和监控方式分布时使用。", "选择该区域,再结合层号检查连续性和切换位置。", "材料与监控方式用于分组解释,不直接表示异常。"], + "theory-rate": ["理论趋势", "理论与实际速率", "对比逐层理论速率和实际速率。", "检查速率偏差或材料分组差异时使用。", "查看同材料曲线及悬停数值,定位偏差层。", "不同材料的设定不同,应在同材料范围内比较。"], + "optical-endpoint": ["光学", "终点透过率趋势", "对比 Meas、动态 Calc 和理论 End T。", "判断全炉光学终点偏移及拟合变化时使用。", "先查看整体趋势,再进入偏移明显的单层详情。", "曲线用于确定复核范围,不替代成品光谱判定。"], + "optical-detail": ["光学", "单层详情", "关联单层 Meas/Calc 曲线、理论曲线和原始截图。", "复核特定层的终点偏差或曲线形态时使用。", "使用前一层和后一层切换,并核对同层截图。", "截图仅辅助核验,不参与统计计算。"], + "device-metric": ["设备", "设备指标", "按层显示功率、温度或真空的均值与波动。", "排查光学偏差对应的设备状态变化时使用。", "选择指标后,比较 H/L 材料曲线及变化范围。", "真空等未声明单位的字段按日志原始值显示。"], + "device-groups": ["设备", "H/L 分组统计", "分别汇总 H/L 材料的样本数、均值和范围。", "避免材料设定差异造成误判时使用。", "先选择设备指标,再对比分组统计和逐层趋势。", "分组统计描述当前炉次,不构成设备规格限值。"], + "anomaly-metric": ["异常关联", "复核指标", "显示稳健 Z 分数识别的相对偏离层。", "需要形成统计复核清单时使用。", "切换指标,查看红圈层并对照异常排名。", "样本不足、MAD 为零或字段恒定时不会检测。"], + "anomaly-correlation": ["异常关联", "相关关系", "列出光学偏差与工艺参数的 Spearman 相关。", "筛选可能共同变化的参数时使用。", "优先查看绝对相关系数较高且样本数充分的关系。", "相关不代表因果,必须结合单层和设备记录复核。"] + }; + + let activeModule = "overview"; + let activeHotspot = "overview-review"; + let selectedLayer = 96; + let deviceMetric = "power"; + let anomalyMetric = "residual"; + + function showDetail(key, reveal = false) { + const detail = details[key]; + if (!detail) return; + activeHotspot = key; + hotspots.forEach(node => node.classList.toggle("active-hotspot", node.dataset.guideHotspot === key)); + $("guideDetailModule").textContent = detail[0]; + $("guideDetailTitle").textContent = detail[1]; + $("guideDetailFunction").textContent = detail[2]; + $("guideDetailWhen").textContent = detail[3]; + $("guideDetailHow").textContent = detail[4]; + $("guideDetailNote").textContent = detail[5]; + if (reveal && window.matchMedia("(max-width: 1050px)").matches) requestAnimationFrame(() => { + const explanation = $("guideExplanation"); + explanation.focus({ preventScroll: true }); + explanation.scrollIntoView({ behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth", block: "start" }); + }); + } + + function setModule(name, focusTab = false) { + activeModule = name; + moduleTabs.forEach(tab => { + const active = tab.dataset.guideModule === name; + tab.classList.toggle("active", active); + tab.setAttribute("aria-selected", String(active)); + tab.tabIndex = active ? 0 : -1; + if (active && focusTab) tab.focus(); + }); + modulePanels.forEach(panel => { + const active = panel.dataset.guidePanel === name; + panel.classList.toggle("active", active); + panel.hidden = !active; + }); + showDetail(moduleFirstHotspot[name]); + requestAnimationFrame(drawAll); + } + + moduleTabs.forEach((tab, index) => { + tab.addEventListener("click", () => setModule(tab.dataset.guideModule)); + tab.addEventListener("keydown", event => { + if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return; + event.preventDefault(); + const previous = event.key === "ArrowLeft" || event.key === "ArrowUp"; + const nextIndex = event.key === "Home" ? 0 : event.key === "End" ? moduleTabs.length - 1 : (index + (previous ? moduleTabs.length - 1 : 1)) % moduleTabs.length; + setModule(moduleTabs[nextIndex].dataset.guideModule, true); + }); + }); + + hotspots.forEach(node => { + node.addEventListener("click", event => { + if (event.target.closest("#guideLayerPrev, #guideLayerNext")) return; + showDetail(node.dataset.guideHotspot, true); + }); + node.addEventListener("keydown", event => { + if (event.key !== "Enter" && event.key !== " ") return; + if (event.currentTarget !== event.target && event.target.closest("button")) return; + event.preventDefault(); + showDetail(node.dataset.guideHotspot, true); + }); + }); + + function frame(canvas) { + const rect = canvas.getBoundingClientRect(); + if (!rect.width || !rect.height) return null; + const ratio = window.devicePixelRatio || 1; + canvas.width = Math.round(rect.width * ratio); + canvas.height = Math.round(rect.height * ratio); + const context = canvas.getContext("2d"); + context.setTransform(ratio, 0, 0, ratio, 0, 0); + return { context, width: rect.width, height: rect.height }; + } + + function lineChart(id, series, options = {}) { + const canvas = $(id); + const target = canvas && frame(canvas); + if (!target) return; + const { context, width, height } = target; + const pad = { left: 34, right: 10, top: 24, bottom: 18 }; + const values = series.flatMap(item => item.values); + const min = options.min ?? Math.min(...values); + const max = options.max ?? Math.max(...values); + const span = max - min || 1; + context.clearRect(0, 0, width, height); + context.font = '9px "Segoe UI", sans-serif'; + context.fillStyle = "#718290"; + context.strokeStyle = "#e1e8ed"; + context.lineWidth = 1; + for (let i = 0; i < 4; i += 1) { + const y = pad.top + (height - pad.top - pad.bottom) * i / 3; + context.beginPath(); context.moveTo(pad.left, y); context.lineTo(width - pad.right, y); context.stroke(); + const value = max - span * i / 3; + context.fillText(options.format ? options.format(value) : value.toFixed(2), 2, y + 3); + } + const point = (value, index, count) => ({ + x: pad.left + (width - pad.left - pad.right) * index / Math.max(1, count - 1), + y: pad.top + (height - pad.top - pad.bottom) * (max - value) / span + }); + series.forEach((item, seriesIndex) => { + const points = item.values.map((value, index) => point(value, index, item.values.length)); + context.strokeStyle = item.color; + context.lineWidth = item.width || 2; + context.beginPath(); + points.forEach((current, index) => { + if (!index) context.moveTo(current.x, current.y); + else if (options.smooth) { + const previous = points[index - 1]; + context.quadraticCurveTo(previous.x, previous.y, (previous.x + current.x) / 2, (previous.y + current.y) / 2); + if (index === points.length - 1) context.lineTo(current.x, current.y); + } else context.lineTo(current.x, current.y); + }); + context.stroke(); + if (item.points) { + context.fillStyle = item.color; + points.forEach(current => { context.beginPath(); context.arc(current.x, current.y, 2.4, 0, Math.PI * 2); context.fill(); }); + } + context.fillStyle = item.color; + context.fillRect(pad.left + seriesIndex * 78, 7, 13, 2); + context.fillStyle = "#586b79"; + context.fillText(item.name, pad.left + 18 + seriesIndex * 78, 10); + }); + (options.alerts || []).forEach(index => { + const value = series[0].values[index]; + const current = point(value, index, series[0].values.length); + context.strokeStyle = "#b42318"; + context.lineWidth = 2; + context.beginPath(); context.arc(current.x, current.y, 6, 0, Math.PI * 2); context.stroke(); + }); + } + + function barChart(id, entries) { + const canvas = $(id); + const target = canvas && frame(canvas); + if (!target) return; + const { context, width, height } = target; + context.clearRect(0, 0, width, height); + context.font = '9px "Segoe UI", sans-serif'; + const left = 82; + const max = Math.max(...entries.map(entry => entry.value)); + entries.forEach((entry, index) => { + const y = 18 + index * ((height - 34) / entries.length); + const barHeight = Math.min(18, (height - 44) / entries.length - 5); + context.fillStyle = "#586b79"; + context.fillText(entry.label, 4, y + barHeight - 3); + context.fillStyle = "#dce6ec"; + context.fillRect(left, y, width - left - 14, barHeight); + context.fillStyle = "#075e91"; + context.fillRect(left, y, (width - left - 14) * entry.value / max, barHeight); + context.fillStyle = "#063b5a"; + context.fillText(entry.value.toFixed(2), width - 32, y + barHeight - 3); + }); + } + + const overviewH = [0.22, 0.35, 0.18, 0.49, 0.31, 0.58, 0.26, 0.43, 0.19, 0.36, 0.28, 0.51]; + const overviewL = [-0.18, -0.31, -0.24, -0.42, -0.21, -0.35, -0.15, -0.29, -0.45, -0.22, -0.34, -0.18]; + const thickness = [121, 78, 118, 82, 124, 76, 119, 84, 122, 79, 117, 81]; + const rateTheory = [0.62, 0.48, 0.61, 0.47, 0.63, 0.49, 0.61, 0.48, 0.62, 0.47, 0.63, 0.48]; + const rateActual = [0.60, 0.50, 0.59, 0.46, 0.64, 0.47, 0.62, 0.49, 0.60, 0.45, 0.65, 0.47]; + const opticalMeas = [0.43, 0.46, 0.44, 0.49, 0.47, 0.52, 0.48, 0.54, 0.50, 0.53, 0.49, 0.55]; + const opticalCalc = [0.42, 0.45, 0.45, 0.48, 0.48, 0.50, 0.49, 0.52, 0.51, 0.52, 0.50, 0.53]; + const opticalTheory = [0.44, 0.44, 0.46, 0.46, 0.48, 0.48, 0.50, 0.50, 0.51, 0.51, 0.52, 0.52]; + + const deviceData = { + power: { title: "功率均值逐层趋势", unit: "日志原始值", h: [5.8, 6.0, 5.9, 6.2, 6.1, 6.3, 6.0, 6.2, 6.1, 6.4, 6.2, 6.3], l: [4.1, 4.2, 4.0, 4.3, 4.2, 4.4, 4.1, 4.3, 4.2, 4.5, 4.3, 4.4] }, + temperature: { title: "腔体温度均值逐层趋势", unit: "°C", h: [214, 216, 218, 217, 219, 221, 220, 222, 221, 223, 224, 223], l: [213, 214, 216, 215, 217, 219, 218, 220, 219, 221, 222, 221] }, + vacuum: { title: "真空均值逐层趋势", unit: "日志原始值", h: [2.2, 2.3, 2.1, 2.4, 2.2, 2.5, 2.3, 2.4, 2.2, 2.5, 2.4, 2.3], l: [2.0, 2.1, 1.9, 2.2, 2.0, 2.3, 2.1, 2.2, 2.0, 2.3, 2.2, 2.1] } + }; + const anomalyData = { + residual: { title: "终点拟合残差", values: [0.11, 0.14, 0.09, 0.18, 0.12, 0.61, 0.16, 0.13, 0.10, 0.17, 0.48, 0.12], alerts: [5, 10] }, + rate: { title: "实际速率偏差", values: [0.01, -0.02, 0.02, -0.01, 0.03, 0.09, -0.02, 0.01, -0.03, 0.02, -0.08, 0.01], alerts: [5, 10] } + }; + + function drawLayer() { + const shift = (selectedLayer - 96) * 0.008; + const meas = [0.18, 0.21, 0.29, 0.42, 0.58, 0.69, 0.63, 0.51, 0.44, 0.47, 0.55, 0.61].map(value => value + shift); + const calc = [0.17, 0.22, 0.31, 0.43, 0.56, 0.66, 0.62, 0.52, 0.45, 0.48, 0.54, 0.59].map(value => value + shift / 2); + lineChart("guideLayerCanvas", [{ name: "Meas", values: meas, color: "#075e91" }, { name: "动态 Calc", values: calc, color: "#c5691a" }], { min: 0.1, max: 0.75, smooth: true }); + } + + function drawDevice() { + const item = deviceData[deviceMetric]; + $("guideDeviceTitle").textContent = item.title; + $("guideDeviceUnit").textContent = item.unit; + lineChart("guideDeviceCanvas", [{ name: "H 材料", values: item.h, color: "#075e91", points: true }, { name: "L 材料", values: item.l, color: "#c5691a", points: true }], { smooth: true }); + const row = (name, values) => { + const mean = values.reduce((sum, value) => sum + value, 0) / values.length; + return `
选择炉次文件夹,Log Analysis 自动关联理论、光学和设备日志,标出需要复核的层。原始数据只读,分析全程留在本机。
+自动关联逐层理论、光学与设备数据,定位需复核层。全程本地运行,原始日志只读。