event_browser: Instantel-printout-style polish
Apply the cheap visual wins from the BW Event Report layout:
1. Channel order reversed → MicL (top), Long, Vert, Tran (bottom)
to match the Instantel printout.
2. Shared bottom time axis — x-axis ticks only render on the
bottom-most data channel; other channels hide ticks so all four
visually share one time scale.
3. Triangle trigger markers above and below the t=0 dashed line.
4. Horizontal zero-baseline (dotted) per channel with "0.0" label
on the right edge — Instantel convention.
5. "Print view" toggle that flips dark→light theme (white panels,
light grids, dark text) so the viewer can render usefully on
paper-style output / @media print.
6. Per-channel PPV stats table in the metadata header, with Peak
Vector Sum displayed prominently.
7. Colors adjusted to approximate BW trace colors (magenta MicL,
blue Long, green Vert, red Tran).
Future PDF-export work will reproduce the same layout server-side
once you upload a real example PDF and we pick a rendering pipeline
(weasyprint / chromium --print-to-pdf / etc.).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+185
-20
@@ -161,7 +161,7 @@
|
||||
background: #161b22;
|
||||
border: 1px solid #21262d;
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px 8px;
|
||||
padding: 10px 30px 8px 12px; /* right padding leaves room for the "0.0" baseline label */
|
||||
}
|
||||
.chart-label {
|
||||
font-size: 11px;
|
||||
@@ -211,6 +211,72 @@
|
||||
font-size: 11px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* Per-channel stats table in the metadata header */
|
||||
.stats-table {
|
||||
grid-column: 1 / -1;
|
||||
border-collapse: collapse;
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.stats-table th, .stats-table td {
|
||||
padding: 3px 14px 3px 0;
|
||||
text-align: left;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
.stats-table th {
|
||||
color: #484f58;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Print view (light theme matching the Instantel printout) ─── */
|
||||
body.print-view {
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
}
|
||||
body.print-view header,
|
||||
body.print-view #event-list-wrap,
|
||||
body.print-view #event-list-header,
|
||||
body.print-view #event-meta,
|
||||
body.print-view #status-bar,
|
||||
body.print-view .chart-wrap {
|
||||
background: #ffffff;
|
||||
border-color: #cccccc;
|
||||
color: #000000;
|
||||
}
|
||||
body.print-view .event-row { color: #000; border-bottom-color: #eee; }
|
||||
body.print-view .event-row:hover { background: #f4f4f4; }
|
||||
body.print-view .event-row.active {
|
||||
background: #e6f0ff;
|
||||
border-left-color: #1f6feb;
|
||||
}
|
||||
body.print-view .er-ts { color: #000; }
|
||||
body.print-view .er-pvs { color: #003a8c; }
|
||||
body.print-view .er-meta,
|
||||
body.print-view #event-list-header,
|
||||
body.print-view .meta-field .mf-label,
|
||||
body.print-view .stats-table th {
|
||||
color: #666;
|
||||
}
|
||||
body.print-view .mf-value { color: #000; }
|
||||
body.print-view .mf-value.highlight { color: #003a8c; }
|
||||
body.print-view label { color: #444; }
|
||||
body.print-view input, body.print-view select {
|
||||
background: #fff; color: #000; border-color: #ccc;
|
||||
}
|
||||
/* In print theme, the channel-label colors stay (they identify
|
||||
the trace). Only the chart panel background flips. */
|
||||
|
||||
@media print {
|
||||
header, #event-list-wrap, #status-bar, button { display: none !important; }
|
||||
body { overflow: visible; height: auto; }
|
||||
#main, #viewer { overflow: visible; }
|
||||
#charts { overflow: visible; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -223,7 +289,8 @@
|
||||
</select>
|
||||
<input type="search" id="event-filter" placeholder="filter events…" />
|
||||
<span class="pill" id="count-pill">—</span>
|
||||
<button id="reload-btn" onclick="loadSerials()" style="margin-left:auto">Reload</button>
|
||||
<button id="print-btn" onclick="togglePrintView()" style="margin-left:auto;background:#21262d">Print view</button>
|
||||
<button id="reload-btn" onclick="loadSerials()">Reload</button>
|
||||
</header>
|
||||
|
||||
<div id="main">
|
||||
@@ -250,13 +317,16 @@
|
||||
<div id="status-bar">Ready.</div>
|
||||
|
||||
<script>
|
||||
// Channel colors and rendering order mirror Instantel's BW Event Report
|
||||
// printout: MicL at the top, Tran at the bottom. Colors approximate
|
||||
// what BW renders (magenta mic, blue long, green vert, red tran).
|
||||
const CHANNEL_COLORS = {
|
||||
Tran: '#58a6ff',
|
||||
MicL: '#e066ff',
|
||||
Long: '#3a80ff',
|
||||
Vert: '#3fb950',
|
||||
Long: '#d29922',
|
||||
MicL: '#bc8cff',
|
||||
Tran: '#f85149',
|
||||
};
|
||||
const CHANNEL_ORDER = ['Tran', 'Vert', 'Long', 'MicL'];
|
||||
const CHANNEL_ORDER = ['MicL', 'Long', 'Vert', 'Tran'];
|
||||
|
||||
let allEvents = [];
|
||||
let filteredEvents = [];
|
||||
@@ -401,14 +471,48 @@ function renderMeta(data, ev) {
|
||||
['Geo range', data.geo_range ? `${data.geo_range} (${data.geo_full_scale_ips} in/s FS)` : '—'],
|
||||
['Project', ev?.project || '—'],
|
||||
['Location', ev?.sensor_location || '—'],
|
||||
['PVS', ev?.peak_vector_sum != null ? `${ev.peak_vector_sum.toFixed(4)} in/s` : '—'],
|
||||
['Peak Vector Sum',
|
||||
ev?.peak_vector_sum != null ? `${ev.peak_vector_sum.toFixed(4)} in/s` : '—'],
|
||||
];
|
||||
metaDiv.innerHTML = fields.map(([l, v]) =>
|
||||
`<div class="meta-field"><span class="mf-label">${l}</span><span class="mf-value${l === 'PVS' ? ' highlight' : ''}">${v}</span></div>`
|
||||
).join('');
|
||||
|
||||
// Per-channel stats table mirroring the printout's middle block.
|
||||
// Pulls per-channel PPV from the events row (DB columns) and additional
|
||||
// details (peak time, peak accel, peak displacement, sensor check) from
|
||||
// bw_report when present.
|
||||
const fmt = v => (v == null ? '—' : (typeof v === 'number' ? v.toFixed(3) : v));
|
||||
const rows = [
|
||||
['Tran', ev?.tran_ppv],
|
||||
['Vert', ev?.vert_ppv],
|
||||
['Long', ev?.long_ppv],
|
||||
];
|
||||
const statsHtml = `
|
||||
<table class="stats-table">
|
||||
<thead>
|
||||
<tr><th>Channel</th><th>PPV (in/s)</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows.map(([ch, ppv]) => `<tr><td>${ch}</td><td>${fmt(ppv)}</td></tr>`).join('')}
|
||||
<tr><td>MicL</td><td>${fmt(ev?.mic_ppv)} psi</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
|
||||
metaDiv.innerHTML =
|
||||
fields.map(([l, v]) =>
|
||||
`<div class="meta-field"><span class="mf-label">${l}</span><span class="mf-value${l === 'Peak Vector Sum' ? ' highlight' : ''}">${v}</span></div>`
|
||||
).join('') + statsHtml;
|
||||
metaDiv.style.display = 'grid';
|
||||
}
|
||||
|
||||
function togglePrintView() {
|
||||
document.body.classList.toggle('print-view');
|
||||
// Force chart redraw so axis/grid colors are re-evaluated against the
|
||||
// new background. Easiest: re-render the current event.
|
||||
if (currentEventId) {
|
||||
loadEvent(currentEventId);
|
||||
}
|
||||
}
|
||||
|
||||
function renderWaveform(data) {
|
||||
document.getElementById('empty-state').style.display = 'none';
|
||||
const chartsDiv = document.getElementById('charts');
|
||||
@@ -420,6 +524,15 @@ function renderWaveform(data) {
|
||||
const channels = data.channels || {};
|
||||
const timeAxis = data.time_axis || null; // ms relative to trigger
|
||||
const triggerMs = data.trigger_ms ?? 0;
|
||||
const isPrintMode = document.body.classList.contains('print-view');
|
||||
|
||||
// Which channels actually have data → determines which one renders the
|
||||
// shared x-axis at the bottom (Instantel printout has the time scale
|
||||
// only on the bottom-most chart).
|
||||
const channelsWithData = CHANNEL_ORDER.filter(ch =>
|
||||
channels[ch] && (channels[ch].values || []).length > 0
|
||||
);
|
||||
const lastDataCh = channelsWithData[channelsWithData.length - 1];
|
||||
|
||||
for (const ch of CHANNEL_ORDER) {
|
||||
const chData = channels[ch];
|
||||
@@ -447,6 +560,9 @@ function renderWaveform(data) {
|
||||
? `peak ${(typeof peak === 'number' ? peak.toExponential(3) : peak)} ${unit}`
|
||||
+ (peakT != null ? ` @ ${peakT.toFixed(1)} ms` : '')
|
||||
: '';
|
||||
// Hide x-axis on every chart except the bottom-most data channel —
|
||||
// gives the "single shared time axis" feel of the BW printout.
|
||||
const showXAxis = (ch === lastDataCh);
|
||||
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'chart-wrap';
|
||||
@@ -510,40 +626,89 @@ function renderWaveform(data) {
|
||||
scales: {
|
||||
x: {
|
||||
type: 'category',
|
||||
display: showXAxis,
|
||||
ticks: {
|
||||
color: '#484f58',
|
||||
color: isPrintMode ? '#666' : '#484f58',
|
||||
maxTicksLimit: 10,
|
||||
maxRotation: 0,
|
||||
callback: (val, i) => rT[i] + ' ms',
|
||||
},
|
||||
grid: { color: '#21262d' },
|
||||
grid: { color: isPrintMode ? '#e0e0e0' : '#21262d', drawTicks: showXAxis },
|
||||
},
|
||||
y: {
|
||||
ticks: { color: '#484f58', maxTicksLimit: 5 },
|
||||
grid: { color: '#21262d' },
|
||||
title: { display: true, text: unit, color: '#484f58', font: { size: 10 } },
|
||||
ticks: { color: isPrintMode ? '#666' : '#484f58', maxTicksLimit: 5 },
|
||||
grid: { color: isPrintMode ? '#e0e0e0' : '#21262d' },
|
||||
title: { display: true, text: unit,
|
||||
color: isPrintMode ? '#666' : '#484f58', font: { size: 10 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [{
|
||||
// Vertical trigger line at t=0
|
||||
id: 'triggerLine',
|
||||
// Trigger line @ t=0 + triangle markers above/below + "0.0"
|
||||
// baseline label on the right edge. Matches the Instantel
|
||||
// BW Event Report printout style.
|
||||
id: 'instantelOverlays',
|
||||
afterDraw(chart) {
|
||||
const ctx = chart.ctx;
|
||||
const xAxis = chart.scales.x;
|
||||
const yAxis = chart.scales.y;
|
||||
const fgPrim = isPrintMode ? '#000' : '#c9d1d9';
|
||||
const fgTrigger = '#f85149';
|
||||
|
||||
// Dashed vertical trigger line at t=0
|
||||
const zeroIdx = rT.findIndex(t => parseFloat(t) >= 0);
|
||||
if (zeroIdx < 0) return;
|
||||
if (zeroIdx >= 0) {
|
||||
const x = xAxis.getPixelForValue(zeroIdx);
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, yAxis.top);
|
||||
ctx.lineTo(x, yAxis.bottom);
|
||||
ctx.strokeStyle = 'rgba(248, 81, 73, 0.7)';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeStyle = isPrintMode ? '#cc0000' : 'rgba(248, 81, 73, 0.8)';
|
||||
ctx.lineWidth = 1.2;
|
||||
ctx.setLineDash([4, 3]);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
// Triangles above and below the chart at the trigger column
|
||||
ctx.save();
|
||||
ctx.fillStyle = fgTrigger;
|
||||
ctx.beginPath(); // top triangle pointing down
|
||||
ctx.moveTo(x - 5, yAxis.top - 8);
|
||||
ctx.lineTo(x + 5, yAxis.top - 8);
|
||||
ctx.lineTo(x, yAxis.top - 1);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.beginPath(); // bottom triangle pointing up
|
||||
ctx.moveTo(x - 5, yAxis.bottom + 8);
|
||||
ctx.lineTo(x + 5, yAxis.bottom + 8);
|
||||
ctx.lineTo(x, yAxis.bottom + 1);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// "0.0" baseline label on the right edge — printout convention.
|
||||
// Position vertically at the zero-amplitude level.
|
||||
const zeroY = yAxis.getPixelForValue(0);
|
||||
if (zeroY >= yAxis.top && zeroY <= yAxis.bottom) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = isPrintMode ? '#aaa' : '#30363d';
|
||||
ctx.lineWidth = 0.8;
|
||||
ctx.setLineDash([2, 2]);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(xAxis.left, zeroY);
|
||||
ctx.lineTo(xAxis.right, zeroY);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = fgPrim;
|
||||
ctx.font = '11px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('0.0', xAxis.right + 6, zeroY);
|
||||
ctx.restore();
|
||||
}
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user