Align timeline metric points with rendered scene geometry
This commit is contained in:
parent
265f2afa50
commit
d5e11987c2
17
PlotLine.Tests/Browser/README.md
Normal file
17
PlotLine.Tests/Browser/README.md
Normal file
@ -0,0 +1,17 @@
|
||||
# Timeline metric browser regressions
|
||||
|
||||
Run from this directory:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
npx playwright install chromium
|
||||
npm test
|
||||
```
|
||||
|
||||
Set `CHROMIUM_PATH` to use an existing Chromium executable. The tests load the production metric-chart initializer, repository CSS, and the same Chart.js version referenced by `_Layout.cshtml`. They use isolated DOM fixtures; no database, login, or running application is required. Keep the pinned Chart.js dependency in step with the layout's CDN version.
|
||||
|
||||
Coverage includes all four timeline views and three densities, one and three books, headers consuming different horizontal widths, unequal scene widths, timelines exceeding 30,000 CSS pixels, start/middle/end scrolling, viewport resizing, section collapse/expand, and device pixel ratios 1 and 2. Assertions compare every scene centre with its chart coordinate, and visible rendered point positions, with a tolerance below half a CSS pixel. Tooltips, metric toggles, point navigation, and drag-save scene identity are exercised; saves are mocked.
|
||||
|
||||
The chart derives coordinates from rendered scene rectangles. A sticky, viewport-sized canvas lives inside the full-width frame in the existing timeline scroll container. The custom Chart.js linear scale uses CSS pixel positions directly, including axis and frame padding, without scaling scene positions to the chart area. ResizeObserver and the timeline layout event invalidate measurements; scroll events only update the visible chart window.
|
||||
|
||||
When scene cards are collapsed after measurement, the last measured geometry remains available. If scene cards are disabled or initially collapsed, the chart retains the standalone ordinal layout until rendered scene geometry becomes available. Analytics and character charts retain their standalone layouts. Raw metric rows remain on their existing CSS grid; they are not part of this chart fix.
|
||||
80
PlotLine.Tests/Browser/package-lock.json
generated
Normal file
80
PlotLine.Tests/Browser/package-lock.json
generated
Normal file
@ -0,0 +1,80 @@
|
||||
{
|
||||
"name": "Browser",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"devDependencies": {
|
||||
"chart.js": "4.4.9",
|
||||
"playwright": "1.58.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@kurkle/color": {
|
||||
"version": "0.3.4",
|
||||
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/chart.js": {
|
||||
"version": "4.4.9",
|
||||
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.4.9.tgz",
|
||||
"integrity": "sha512-EyZ9wWKgpAU0fLJ43YAEIF8sr5F2W3LqbS40ZJyHIner2lY14ufqv2VMp69MAiZ2rpwxEUxEhIH/0U3xyRynxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@kurkle/color": "^0.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"pnpm": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
5
PlotLine.Tests/Browser/package.json
Normal file
5
PlotLine.Tests/Browser/package.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"private": true,
|
||||
"scripts": { "test": "node timeline-metric-layout.cjs" },
|
||||
"devDependencies": { "chart.js": "4.4.9", "playwright": "1.58.2" }
|
||||
}
|
||||
198
PlotLine.Tests/Browser/timeline-metric-layout.cjs
Normal file
198
PlotLine.Tests/Browser/timeline-metric-layout.cjs
Normal file
@ -0,0 +1,198 @@
|
||||
// npm install && npx playwright install chromium && npm test
|
||||
// Optional: CHROMIUM_PATH=/path/to/chrome npm test
|
||||
const { chromium } = require('playwright');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const assert = require('node:assert/strict');
|
||||
const repo = path.resolve(__dirname, '../..');
|
||||
const source = fs.readFileSync(path.join(repo, 'PlotLine/wwwroot/js/site.js'), 'utf8');
|
||||
const start = source.indexOf('(() => {\n const chartRoots =');
|
||||
const chartScript = source.slice(start, source.indexOf('\n})();', start) + 6);
|
||||
const css = fs.readFileSync(path.join(repo, 'PlotLine/wwwroot/css/site.css'), 'utf8');
|
||||
const chartJs = path.join(path.dirname(require.resolve('chart.js')), 'chart.umd.js');
|
||||
const settle = async page => {
|
||||
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))));
|
||||
await page.waitForTimeout(100);
|
||||
};
|
||||
|
||||
async function fixture(page, bookCount, sceneCount, variedHeaders = false, mode = "visible") {
|
||||
const scenes = [];
|
||||
let books = '';
|
||||
for (let book = 0; book < bookCount; book++) {
|
||||
let chapters = '';
|
||||
for (let chapter = 0; chapter < 3; chapter++) {
|
||||
let cards = '';
|
||||
for (let n = 0; n < sceneCount; n++) {
|
||||
const id = scenes.length + 1;
|
||||
scenes.push({ sceneId: id, sceneNumber: id, chapterLabel: `Chapter ${chapter + 1}`,
|
||||
sceneTitle: `Scene ${id}`, axisLabel: `S${id}`, bookTitle: `Book ${book + 1}`, timelineUrl: `https://plotdirector.test/Timeline?selectedSceneId=${id}` });
|
||||
cards += `<a class="timeline-scene-card" data-scene-id="${id}">Scene ${id}</a>`;
|
||||
}
|
||||
chapters += `<div class="timeline-chapter" style="--chapter-scene-count:${sceneCount}"><div class="timeline-chapter-title">Chapter ${chapter + 1}</div><div class="timeline-scenes">${cards}</div></div>`;
|
||||
}
|
||||
books += `<div class="timeline-book" style="--book-scene-count:${sceneCount * 3}"><div class="timeline-book-title" ${variedHeaders ? `style="width:${137 + book * 89}px"` : ''}>Book ${book + 1}</div><div class="timeline-chapters">${chapters}</div></div>`;
|
||||
}
|
||||
const payload = { scenes, metrics: ['Tension', 'Action'].map((metricName, i) => ({ metricName, metricTypeId: i + 1,
|
||||
points: scenes.map(s => ({ sceneId: s.sceneId, value: 3 + s.sceneId % 5 })) })) };
|
||||
await page.setContent(`<style>*{box-sizing:border-box}body{margin:0}${css}
|
||||
.timeline-filtered-root{--timeline-label-width:180px;--timeline-grid-gap:10px}
|
||||
${variedHeaders ? '.timeline-book{display:flex;width:max-content;gap:23px}.timeline-book-title{flex-shrink:0}' : ''}
|
||||
</style><div class="timeline-filtered-root density-comfortable zoom-chapter" data-timeline-root>
|
||||
<section class="timeline-shell"><div data-collapse-body="scene-cards"><div class="timeline-series-row"><div class="timeline-project-title">Project</div><div class="timeline-books">${books}</div></div></div>
|
||||
<div class="metric-timeline-board" style="--scene-count:${scenes.length}"><div data-collapse-body="metrics"><div class="metric-shape-chart" data-metric-shape-chart data-timeline-metric-editor>
|
||||
<div data-metric-toggles></div><div data-timeline-metric-edit-toolbar><input name="__RequestVerificationToken" value="test"></div><span data-edit-metric-status></span>
|
||||
<div class="metric-timeline-row"><div class="metric-lane-label">Metric Shape</div><div class="metric-chart-frame metric-timeline-frame"><canvas></canvas></div></div>
|
||||
<script type="application/json">${JSON.stringify(payload)}</script></div></div></div></section></div>`);
|
||||
if (mode !== "visible") {
|
||||
await page.evaluate(mode => {
|
||||
const body = document.querySelector('[data-collapse-body="scene-cards"]');
|
||||
if (mode === "disabled") body.remove();
|
||||
else body.classList.add('timeline-section-collapsed');
|
||||
}, mode);
|
||||
}
|
||||
await page.addScriptTag({ path: chartJs });
|
||||
await page.addScriptTag({ content: chartScript });
|
||||
await settle(page);
|
||||
}
|
||||
|
||||
async function checkAlignment(page, label) {
|
||||
const result = await page.evaluate(() => {
|
||||
const chart = Chart.getChart(document.querySelector('canvas'));
|
||||
const left = chart.canvas.getBoundingClientRect().left;
|
||||
const points = chart.data.datasets[0].data;
|
||||
const errors = points.filter(p => p.sceneId).map(p => {
|
||||
const rect = document.querySelector(`[data-scene-id="${p.sceneId}"]`).getBoundingClientRect();
|
||||
return Math.abs(left + chart.scales.x.getPixelForValue(p.x) - rect.left - rect.width / 2);
|
||||
});
|
||||
const drawnErrors = chart.getDatasetMeta(0).data.flatMap((point, i) => {
|
||||
if (point.skip || point.x < chart.chartArea.left || point.x > chart.chartArea.right) return [];
|
||||
const rect = document.querySelector(`[data-scene-id="${points[i].sceneId}"]`).getBoundingClientRect();
|
||||
return [Math.abs(left + point.x - rect.left - rect.width / 2)];
|
||||
});
|
||||
return { max: Math.max(...errors), drawnMax: Math.max(0, ...drawnErrors), bitmap: chart.canvas.width,
|
||||
limit: document.querySelector('.timeline-shell').clientWidth * devicePixelRatio + 1 };
|
||||
});
|
||||
assert.ok(result.max < 0.5, `${label}: scale drift ${JSON.stringify(result)}`);
|
||||
assert.ok(result.drawnMax < 0.5, `${label}: rendered drift ${JSON.stringify(result)}`);
|
||||
assert.ok(result.bitmap <= result.limit, `${label}: canvas exceeds viewport`);
|
||||
}
|
||||
|
||||
async function checkInteractions(page) {
|
||||
await page.evaluate(() => {
|
||||
const canvas = document.querySelector('canvas');
|
||||
canvas.scrollIntoView({ block: 'center' });
|
||||
const shell = document.querySelector('.timeline-shell');
|
||||
shell.scrollLeft = (shell.scrollWidth - shell.clientWidth) / 2;
|
||||
});
|
||||
await settle(page);
|
||||
const point = await page.evaluate(() => {
|
||||
const chart = Chart.getChart(document.querySelector('canvas'));
|
||||
const index = chart.getDatasetMeta(0).data.findIndex(p => !p.skip && p.x > chart.width / 2 && p.x < chart.chartArea.right - 20);
|
||||
const p = chart.getDatasetMeta(0).data[index];
|
||||
const rect = chart.canvas.getBoundingClientRect();
|
||||
return { index, sceneId: chart.data.datasets[0].data[index].sceneId, x: rect.left + p.x, y: rect.top + p.y };
|
||||
});
|
||||
assert.ok(point.index > 0, 'exercise a scene well beyond the first');
|
||||
await page.mouse.move(point.x, point.y);
|
||||
await settle(page);
|
||||
assert.ok(await page.evaluate(id => {
|
||||
const chart = Chart.getChart(document.querySelector('canvas'));
|
||||
return chart.tooltip.title[0].endsWith(`Scene ${id}`);
|
||||
}, point.sceneId), 'tooltip must resolve scene identity from dataset index');
|
||||
|
||||
await page.evaluate(() => {
|
||||
const checkboxes = document.querySelectorAll('[data-metric-toggles] input');
|
||||
checkboxes[1].click();
|
||||
window.savedMetric = null;
|
||||
window.fetch = async (_, options) => {
|
||||
window.savedMetric = JSON.parse(options.body);
|
||||
return { ok: true, json: async () => ({ success: true, value: window.savedMetric.value }) };
|
||||
};
|
||||
});
|
||||
await page.mouse.move(point.x, point.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(point.x, point.y - 25, { steps: 3 });
|
||||
await page.mouse.up();
|
||||
await settle(page);
|
||||
const saved = await page.evaluate(() => window.savedMetric);
|
||||
assert.equal(saved.sceneId, point.sceneId, 'drag saves the correct scene');
|
||||
assert.equal(saved.metricId, 1);
|
||||
await checkAlignment(page, 'after metric editing');
|
||||
await page.evaluate(() => document.querySelectorAll('[data-metric-toggles] input')[1].click());
|
||||
assert.equal(await page.evaluate(() => Chart.getChart(document.querySelector('canvas')).isDatasetVisible(1)), true);
|
||||
|
||||
const navigation = page.waitForRequest(request => request.url().includes('selectedSceneId='));
|
||||
await page.route('https://plotdirector.test/**', route => route.abort());
|
||||
await page.evaluate(index => {
|
||||
const chart = Chart.getChart(document.querySelector('canvas'));
|
||||
const p = chart.getDatasetMeta(0).data[index];
|
||||
const rect = chart.canvas.getBoundingClientRect();
|
||||
chart.canvas.dispatchEvent(new MouseEvent('click', { bubbles: true, clientX: rect.left + p.x, clientY: rect.top + p.y }));
|
||||
}, point.index);
|
||||
assert.ok((await navigation).url().endsWith(`selectedSceneId=${point.sceneId}`), 'point click targets its scene');
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_PATH });
|
||||
try {
|
||||
for (const dpr of [1, 2]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1100, height: 900 }, deviceScaleFactor: dpr });
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(error.message));
|
||||
for (const [books, count, headers] of [[1, 12, false], [3, 18, true]]) {
|
||||
await fixture(page, books, count, headers);
|
||||
for (const density of ['compact', 'comfortable', 'expanded']) {
|
||||
for (const zoom of ['book', 'chapter', 'scene-detail', 'scene-overview']) {
|
||||
await page.evaluate(({ density, zoom }) => {
|
||||
document.querySelector('[data-timeline-root]').className = `timeline-filtered-root density-${density} zoom-${zoom}`;
|
||||
}, { density, zoom });
|
||||
await settle(page);
|
||||
for (const fraction of [0, 0.5, 1]) {
|
||||
await page.evaluate(f => {
|
||||
const shell = document.querySelector('.timeline-shell');
|
||||
shell.scrollLeft = (shell.scrollWidth - shell.clientWidth) * f;
|
||||
}, fraction);
|
||||
await settle(page);
|
||||
await checkAlignment(page, `${books} books ${density}/${zoom} scroll=${fraction} dpr=${dpr}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
await page.setViewportSize({ width: 760, height: 900 });
|
||||
await settle(page);
|
||||
await checkAlignment(page, 'resize');
|
||||
// Width changes without an application event must trigger ResizeObserver.
|
||||
await page.evaluate(() => {
|
||||
document.querySelectorAll('.timeline-scene-card').forEach((el, i) => el.style.width = `${120 + i % 4 * 73}px`);
|
||||
document.querySelectorAll('.timeline-chapter,.timeline-book').forEach(el => el.style.width = 'max-content');
|
||||
});
|
||||
await settle(page);
|
||||
await checkAlignment(page, 'unequal scene widths');
|
||||
for (const section of ['metrics', 'scene-cards']) {
|
||||
await page.evaluate(section => document.querySelector(`[data-collapse-body="${section}"]`).classList.add('timeline-section-collapsed'), section);
|
||||
await settle(page);
|
||||
await page.evaluate(section => {
|
||||
document.querySelector(`[data-collapse-body="${section}"]`).classList.remove('timeline-section-collapsed');
|
||||
window.dispatchEvent(new Event('plotline:timeline-layout-changed'));
|
||||
}, section);
|
||||
await settle(page);
|
||||
await checkAlignment(page, `expand ${section}`);
|
||||
}
|
||||
await page.setViewportSize({ width: 1100, height: 900 });
|
||||
}
|
||||
await fixture(page, 1, 6, false, 'collapsed');
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('[data-collapse-body="scene-cards"]').classList.remove('timeline-section-collapsed');
|
||||
window.dispatchEvent(new Event('plotline:timeline-layout-changed'));
|
||||
});
|
||||
await settle(page);
|
||||
await checkAlignment(page, 'initially collapsed scene cards');
|
||||
await fixture(page, 1, 6, false, 'disabled');
|
||||
assert.equal(await page.evaluate(() => Chart.getChart(document.querySelector('canvas')).scales.x.type), 'linear', 'standalone layout remains supported');
|
||||
await fixture(page, 3, 18, true);
|
||||
await checkInteractions(page);
|
||||
assert.deepEqual(errors, []);
|
||||
await page.close();
|
||||
}
|
||||
console.log('PASS: timeline metric geometry and interactions (views/densities, single/multi-book, unequal widths, scrolling, resize, collapse/disabled cards, DPR 1/2, long timelines, tooltips, toggles, click, drag-save)');
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@ -1747,7 +1747,7 @@ body.timeline-inspector-resizing {
|
||||
|
||||
.metric-timeline-row {
|
||||
display: grid;
|
||||
grid-template-columns: var(--timeline-label-width) calc((var(--scene-count) * var(--timeline-scene-width)) + ((var(--scene-count) - 1) * var(--timeline-grid-gap)));
|
||||
grid-template-columns: var(--timeline-label-width) var(--metric-layout-width, calc((var(--scene-count) * var(--timeline-scene-width)) + ((var(--scene-count) - 1) * var(--timeline-grid-gap))));
|
||||
gap: var(--timeline-grid-gap);
|
||||
align-items: stretch;
|
||||
}
|
||||
@ -3004,6 +3004,19 @@ body.timeline-inspector-resizing {
|
||||
width: calc((var(--scene-count) * var(--timeline-scene-width)) + ((var(--scene-count) - 1) * var(--timeline-grid-gap)));
|
||||
}
|
||||
|
||||
/* The full frame shares the scene scroll space; only its bitmap is viewport sized. */
|
||||
.metric-timeline-measured {
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.metric-timeline-viewport {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
height: 300px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.metric-chart-frame canvas {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
|
||||
2
PlotLine/wwwroot/css/site.min.css
vendored
2
PlotLine/wwwroot/css/site.min.css
vendored
File diff suppressed because one or more lines are too long
@ -743,7 +743,11 @@
|
||||
};
|
||||
const resizeMetricCharts = () => {
|
||||
window.requestAnimationFrame(() => {
|
||||
metricCharts.forEach(({ chart, root }) => {
|
||||
metricCharts.forEach(({ chart, root, timelineLayout }) => {
|
||||
if (timelineLayout) {
|
||||
timelineLayout.schedule(true);
|
||||
return;
|
||||
}
|
||||
syncMetricCanvasSize(root);
|
||||
chart.resize();
|
||||
});
|
||||
@ -769,7 +773,7 @@
|
||||
|
||||
const chapterStarts = [];
|
||||
scenes.forEach((scene, index) => {
|
||||
if (!index || scene.chapterLabel !== scenes[index - 1].chapterLabel) {
|
||||
if (!index || scene.chapterLabel !== scenes[index - 1].chapterLabel || scene.bookTitle !== scenes[index - 1].bookTitle) {
|
||||
chapterStarts.push({ scene, index });
|
||||
}
|
||||
});
|
||||
@ -778,7 +782,8 @@
|
||||
|
||||
realChapterStarts.forEach((entry, chapterIndex) => {
|
||||
const { scene, index } = entry;
|
||||
const x = scales.x.getPixelForValue(index + 0.5);
|
||||
const x = scales.x.getPixelForValue(chart.data.datasets[0].data[index].x);
|
||||
if (!Number.isFinite(x) || x < chartArea.left || x > chartArea.right) return;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, chartArea.top);
|
||||
ctx.lineTo(x, chartArea.bottom);
|
||||
@ -800,6 +805,18 @@
|
||||
|
||||
if (window.Chart) {
|
||||
Chart.register(chapterGuidePlugin);
|
||||
// A timeline unit is one CSS pixel, independent of Chart.js axis/padding layout.
|
||||
// The canvas is a moving window onto the existing scrollable scene layout.
|
||||
class TimelinePositionScale extends Chart.LinearScale {
|
||||
static id = "timelinePosition";
|
||||
getPixelForValue(value) {
|
||||
return Number(value) - (this.chart.$timelineViewportLeft || 0);
|
||||
}
|
||||
getValueForPixel(pixel) {
|
||||
return pixel + (this.chart.$timelineViewportLeft || 0);
|
||||
}
|
||||
}
|
||||
Chart.register(TimelinePositionScale);
|
||||
}
|
||||
|
||||
chartRoots.forEach((root) => {
|
||||
@ -827,6 +844,19 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const timelineShell = root.closest(".timeline-shell");
|
||||
const sceneRow = timelineShell?.querySelector(".timeline-series-row");
|
||||
const frame = root.querySelector(".metric-chart-frame");
|
||||
const sceneElements = new Map(Array.from(sceneRow?.querySelectorAll(".timeline-scene-card[data-scene-id]") || [],
|
||||
(element) => [Number(element.dataset.sceneId), element]));
|
||||
const viewport = timelineShell && sceneRow && sceneElements.size ? document.createElement("div") : null;
|
||||
let timelineLayout = null;
|
||||
let hasSceneGeometry = false;
|
||||
if (viewport) {
|
||||
viewport.className = "metric-timeline-viewport";
|
||||
canvas.before(viewport);
|
||||
viewport.append(canvas);
|
||||
}
|
||||
const defaultMetrics = new Set(payload.defaultMetrics?.length ? payload.defaultMetrics : globalDefaultMetrics);
|
||||
const columnCount = payload.scenes.length;
|
||||
const tickLabelInterval = columnCount > 80 ? 10 : columnCount > 40 ? 5 : columnCount > 24 ? 4 : columnCount > 14 ? 2 : 1;
|
||||
@ -922,6 +952,7 @@
|
||||
data: { datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
animation: viewport ? false : undefined,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: "nearest", intersect: false },
|
||||
scales: {
|
||||
@ -941,7 +972,7 @@
|
||||
autoSkip: false,
|
||||
maxTicksLimit: 16,
|
||||
callback(value) {
|
||||
const columnIndex = Math.floor(Number(value));
|
||||
const columnIndex = datasets[0].data.findIndex((point) => point.x === Number(value));
|
||||
if (columnIndex < 0 || columnIndex >= columnCount) {
|
||||
return "";
|
||||
}
|
||||
@ -954,7 +985,9 @@
|
||||
}
|
||||
},
|
||||
afterBuildTicks(scale) {
|
||||
scale.ticks = payload.scenes.map((_, columnIndex) => ({ value: columnIndex + 0.5 }));
|
||||
scale.ticks = datasets[0].data
|
||||
.filter((point) => Number.isFinite(point.x) && (!hasSceneGeometry || (point.x >= scale.min && point.x <= scale.max)))
|
||||
.map((point) => ({ value: point.x }));
|
||||
},
|
||||
grid: { display: false }
|
||||
}
|
||||
@ -965,7 +998,7 @@
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title(items) {
|
||||
const columnIndex = Math.floor(items[0].parsed.x);
|
||||
const columnIndex = items[0].dataIndex;
|
||||
const scene = payload.scenes[columnIndex];
|
||||
if (scene.isPlaceholder) {
|
||||
return scene.sceneTitle || "No scene";
|
||||
@ -976,7 +1009,7 @@
|
||||
return `${item.dataset.label}: ${item.formattedValue}`;
|
||||
},
|
||||
afterBody(items) {
|
||||
const columnIndex = Math.floor(items[0].parsed.x);
|
||||
const columnIndex = items[0].dataIndex;
|
||||
const scene = payload.scenes[columnIndex];
|
||||
const lines = [];
|
||||
if (scene.characterPresence) {
|
||||
@ -1142,8 +1175,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
const point = chart.data.datasets[points[0].datasetIndex]?.data?.[points[0].index];
|
||||
const columnIndex = point ? Math.floor(point.x) : points[0].index;
|
||||
const columnIndex = points[0].index;
|
||||
const scene = payload.scenes[columnIndex];
|
||||
if (scene?.timelineUrl) {
|
||||
window.PlotLineTimelineScroll?.save();
|
||||
@ -1151,17 +1183,78 @@
|
||||
}
|
||||
});
|
||||
|
||||
metricCharts.push({ chart, root });
|
||||
if (viewport) {
|
||||
let pendingFrame = 0;
|
||||
let needsMeasurement = false;
|
||||
const refresh = () => {
|
||||
pendingFrame = 0;
|
||||
if (!root.getBoundingClientRect().width) return;
|
||||
if (needsMeasurement) {
|
||||
needsMeasurement = false;
|
||||
const rowRect = sceneRow.getBoundingClientRect();
|
||||
if (rowRect.width) {
|
||||
const origin = frame.getBoundingClientRect().left;
|
||||
const centres = new Map();
|
||||
let width = rowRect.right - origin;
|
||||
sceneElements.forEach((element, sceneId) => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width) {
|
||||
centres.set(sceneId, rect.left + rect.width / 2 - origin);
|
||||
width = Math.max(width, rect.right - origin);
|
||||
}
|
||||
});
|
||||
if (centres.size) {
|
||||
root.style.setProperty("--metric-layout-width", `${width}px`);
|
||||
frame.style.width = `${width}px`;
|
||||
frame.classList.add("metric-timeline-measured");
|
||||
datasets.forEach((dataset) => dataset.data.forEach((point) => {
|
||||
// Missing/placeholder scenes have no rendered centre and no plotted point.
|
||||
point.x = centres.get(point.sceneId) ?? NaN;
|
||||
}));
|
||||
hasSceneGeometry = true;
|
||||
chart.options.scales.x.type = "timelinePosition";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (hasSceneGeometry) {
|
||||
// Bound the bitmap to the viewport, never compress the timeline to a canvas limit.
|
||||
viewport.style.width = `${Math.min(timelineShell.clientWidth, frame.clientWidth,
|
||||
Math.floor(maxMetricCanvasDimension / Math.max(window.devicePixelRatio || 1, 1)))}px`;
|
||||
chart.$timelineViewportLeft = canvas.getBoundingClientRect().left - frame.getBoundingClientRect().left;
|
||||
chart.options.scales.x.min = chart.$timelineViewportLeft;
|
||||
chart.options.scales.x.max = chart.$timelineViewportLeft + viewport.clientWidth;
|
||||
} else {
|
||||
syncMetricCanvasSize(root);
|
||||
}
|
||||
chart.resize();
|
||||
chart.update("none");
|
||||
};
|
||||
const schedule = (measure = false) => {
|
||||
needsMeasurement ||= measure;
|
||||
if (!pendingFrame) pendingFrame = window.requestAnimationFrame(refresh);
|
||||
};
|
||||
timelineLayout = { schedule };
|
||||
// Scrolling only moves the viewport; scene measurements are cached until layout changes.
|
||||
timelineShell.addEventListener("scroll", () => schedule(), { passive: true });
|
||||
window.addEventListener("resize", () => schedule(true));
|
||||
const observer = window.ResizeObserver ? new ResizeObserver(() => schedule(true)) : null;
|
||||
observer?.observe(timelineShell);
|
||||
observer?.observe(sceneRow);
|
||||
observer?.observe(root);
|
||||
sceneRow.querySelectorAll(".timeline-book, .timeline-book-title, .timeline-chapter, .timeline-scenes, .timeline-scene-card")
|
||||
.forEach((element) => observer?.observe(element));
|
||||
schedule(true);
|
||||
} else {
|
||||
const resizeObserver = window.ResizeObserver
|
||||
? new ResizeObserver(() => {
|
||||
syncMetricCanvasSize(root);
|
||||
chart.resize();
|
||||
})
|
||||
: null;
|
||||
if (resizeObserver) {
|
||||
resizeObserver.observe(root);
|
||||
resizeObserver.observe(root.querySelector(".metric-chart-frame") || root);
|
||||
resizeObserver?.observe(root);
|
||||
resizeObserver?.observe(frame || root);
|
||||
}
|
||||
metricCharts.push({ chart, root, timelineLayout });
|
||||
});
|
||||
})();
|
||||
|
||||
@ -1576,7 +1669,7 @@
|
||||
densitySelect.value = nextValue;
|
||||
}
|
||||
localStorage.setItem(densityKey, nextValue);
|
||||
window.requestAnimationFrame(renderPlotLineSvg);
|
||||
window.dispatchEvent(new Event("plotline:timeline-layout-changed"));
|
||||
};
|
||||
|
||||
const applyZoom = (value) => {
|
||||
@ -1586,7 +1679,7 @@
|
||||
zoomSelect.value = nextValue;
|
||||
}
|
||||
localStorage.setItem(zoomKey, nextValue);
|
||||
window.requestAnimationFrame(renderPlotLineSvg);
|
||||
window.dispatchEvent(new Event("plotline:timeline-layout-changed"));
|
||||
};
|
||||
|
||||
const getScene = (sceneId) => root.querySelector(`[data-scene-id='${CSS.escape(String(sceneId))}']`);
|
||||
|
||||
2
PlotLine/wwwroot/js/site.min.js
vendored
2
PlotLine/wwwroot/js/site.min.js
vendored
File diff suppressed because one or more lines are too long
Loading…
x
Reference in New Issue
Block a user