Changed metric graph to align with scenes.

This commit is contained in:
Nick Beckley 2026-06-03 09:52:49 +01:00
parent b5a6de4fb2
commit 2ad5865c58
3 changed files with 232 additions and 100 deletions

View File

@ -3,27 +3,6 @@
ViewData["Title"] = "Timeline";
ViewData["ShellClass"] = "plotline-shell-wide";
var selectedBook = Model.Books.FirstOrDefault(x => x.Book.BookID == Model.SelectedBookID)?.Book;
var timelineMetricScenes = Model.Books
.SelectMany(book => book.Chapters.SelectMany(chapter => chapter.Scenes.Select(scene => new
{
sceneId = scene.SceneID,
axisLabel = $"{chapter.Chapter.ChapterNumber:g}.{scene.SceneNumber:g}",
chapterLabel = $"Chapter {chapter.Chapter.ChapterNumber:g}",
sceneNumber = scene.SceneNumber.ToString("g"),
sceneTitle = scene.SceneTitle,
bookTitle = book.Book.BookTitle,
timelineUrl = Url.Action("Index", "Timeline", new { projectId = Model.Project.ProjectID, bookId = book.Book.BookID, selectedSceneId = scene.SceneID })
})))
.ToList();
var timelineMetricShapePayload = new
{
scenes = timelineMetricScenes,
metrics = Model.MetricGraphs.Select(graph => new
{
metricName = graph.MetricName,
points = graph.Points.Select(point => new { sceneId = point.SceneID, value = point.Value })
})
};
var hasActiveFilter = Model.MatchingSceneIDs.Count != Model.OrderedScenes.Count || !string.IsNullOrWhiteSpace(Model.FocusLabel);
var warningScenes = Model.OrderedScenes.Where(x => x.WarningCount > 0).ToList();
var timelineColumns = new List<(BookTimelineViewModel Book, ChapterTimelineViewModel? Chapter, Scene? Scene, bool IsPlaceholder, string PlaceholderLabel)>();
@ -49,6 +28,26 @@
}
}
}
var timelineMetricColumns = timelineColumns.Select(column => new
{
sceneId = column.Scene?.SceneID,
isPlaceholder = column.Scene is null,
axisLabel = column.Scene is null ? string.Empty : $"Ch {column.Chapter?.Chapter.ChapterNumber:g}.{column.Scene.SceneNumber:g}",
chapterLabel = column.Chapter is null ? column.Book.Book.BookTitle : $"Chapter {column.Chapter.Chapter.ChapterNumber:g}",
sceneNumber = column.Scene?.SceneNumber.ToString("g"),
sceneTitle = column.Scene?.SceneTitle ?? column.PlaceholderLabel,
bookTitle = column.Book.Book.BookTitle,
timelineUrl = column.Scene is null ? null : Url.Action("Index", "Timeline", new { projectId = Model.Project.ProjectID, bookId = column.Book.Book.BookID, selectedSceneId = column.Scene.SceneID })
}).ToList();
var timelineMetricShapePayload = new
{
scenes = timelineMetricColumns,
metrics = Model.MetricGraphs.Select(graph => new
{
metricName = graph.MetricName,
points = graph.Points.Select(point => new { sceneId = point.SceneID, value = point.Value })
})
};
int ChapterSceneColumns(ChapterTimelineViewModel chapter) => Math.Max(chapter.Scenes.Count, 1);
int BookSceneColumns(BookTimelineViewModel book) => Math.Max(book.Chapters.Any() ? book.Chapters.Sum(ChapterSceneColumns) : 1, 1);
var hasSelectedScene = Model.SelectedScene is not null;
@ -705,6 +704,79 @@ else
</div>
</div>
</div>
<div class="metric-timeline-board" data-timeline-section="metrics" style="--scene-count:@timelineColumns.Count">
<div class="lane-board-heading metric-lane-board-heading">
<button type="button" data-collapse-toggle="metrics">Metric Shape</button>
<small>@Model.OrderedScenes.Count scene@(Model.OrderedScenes.Count == 1 ? "" : "s")</small>
</div>
<div data-collapse-body="metrics">
@if (!Model.MetricGraphs.Any() || !Model.OrderedScenes.Any())
{
<div class="future-lane-placeholder">
Add scenes to begin seeing emotional and pacing shape.
</div>
}
else
{
<div class="metric-shape-chart metric-shape-timeline-chart" data-metric-shape-chart>
<div class="metric-shape-controls">
<p class="metric-shape-intro">Compare selected metrics across the same scene timeline. Click a point to focus that scene.</p>
<div class="metric-toggle-row" data-metric-toggles></div>
</div>
<div class="metric-timeline-row">
<div class="metric-lane-label">
<strong>Metric Shape</strong>
<span>Scene pacing</span>
</div>
<div class="metric-chart-frame metric-timeline-frame">
<canvas aria-label="Comparative scene metric line graph"></canvas>
<p class="muted d-none" data-chart-fallback>Metric chart could not load. The raw metric rows are still available below.</p>
</div>
</div>
<script type="application/json">@Html.Raw(System.Text.Json.JsonSerializer.Serialize(timelineMetricShapePayload))</script>
</div>
<details class="metric-raw-details">
<summary>Show raw metric rows</summary>
<div class="metric-graph-stack mt-2">
@foreach (var graph in Model.MetricGraphs)
{
<div class="metric-graph-row" style="--scene-count:@Math.Max(timelineColumns.Count, 1)">
<div class="metric-graph-label">@graph.MetricName</div>
<div class="metric-graph-points" style="--scene-count:@Math.Max(timelineColumns.Count, 1)">
@foreach (var column in timelineColumns)
{
if (column.Scene is null)
{
<span class="metric-point metric-point-placeholder timeline-placeholder-cell is-placeholder" title="@column.PlaceholderLabel"><span>No value</span></span>
continue;
}
var point = graph.Points.FirstOrDefault(x => x.SceneID == column.Scene.SceneID);
if (point is null)
{
<span class="metric-point metric-point-placeholder timeline-placeholder-cell is-placeholder" title="No metric value"><span>No value</span></span>
continue;
}
<a asp-controller="Timeline"
asp-action="Index"
asp-route-projectId="@Model.Project.ProjectID"
asp-route-bookId="@Model.SelectedBookID"
asp-route-selectedSceneId="@point.SceneID"
class="metric-point"
style="--metric-height:@point.Percent%"
title="Scene @point.SceneNumber: @point.SceneTitle - @point.Value">
<span>@point.Value</span>
</a>
}
</div>
</div>
}
</div>
</details>
}
</div>
</div>
@if (Model.PlotLanes.Any() && Model.OrderedScenes.Any())
{
<div class="plot-lane-board" data-timeline-section="plot-lines" style="--scene-count:@timelineColumns.Count">
@ -908,73 +980,6 @@ else
</div>
}
</section>
<section class="pacing-panel" data-timeline-section="metrics">
<div class="pacing-heading">
<div>
<p class="eyebrow">Pacing</p>
<h2><button type="button" data-collapse-toggle="metrics">Metric shape</button></h2>
</div>
<span class="muted">@Model.OrderedScenes.Count scene@(Model.OrderedScenes.Count == 1 ? "" : "s")</span>
</div>
<div data-collapse-body="metrics">
@if (!Model.MetricGraphs.Any() || !Model.OrderedScenes.Any())
{
<p class="muted">Add scenes to begin seeing emotional and pacing shape.</p>
}
else
{
<div class="metric-shape-chart" data-metric-shape-chart>
<p class="metric-shape-intro">Compare selected metrics across the same scene timeline. Click a point to focus that scene.</p>
<div class="metric-toggle-row" data-metric-toggles></div>
<div class="metric-chart-frame">
<canvas aria-label="Comparative scene metric line graph"></canvas>
<p class="muted d-none" data-chart-fallback>Metric chart could not load. The raw metric rows are still available below.</p>
</div>
<script type="application/json">@Html.Raw(System.Text.Json.JsonSerializer.Serialize(timelineMetricShapePayload))</script>
</div>
<details class="metric-raw-details">
<summary>Show raw metric rows</summary>
<div class="metric-graph-stack mt-2">
@foreach (var graph in Model.MetricGraphs)
{
<div class="metric-graph-row" style="--scene-count:@Math.Max(timelineColumns.Count, 1)">
<div class="metric-graph-label">@graph.MetricName</div>
<div class="metric-graph-points" style="--scene-count:@Math.Max(timelineColumns.Count, 1)">
@foreach (var column in timelineColumns)
{
if (column.Scene is null)
{
<span class="metric-point metric-point-placeholder timeline-placeholder-cell is-placeholder" title="@column.PlaceholderLabel"><span>No value</span></span>
continue;
}
var point = graph.Points.FirstOrDefault(x => x.SceneID == column.Scene.SceneID);
if (point is null)
{
<span class="metric-point metric-point-placeholder timeline-placeholder-cell is-placeholder" title="No metric value"><span>No value</span></span>
continue;
}
<a asp-controller="Timeline"
asp-action="Index"
asp-route-projectId="@Model.Project.ProjectID"
asp-route-bookId="@Model.SelectedBookID"
asp-route-selectedSceneId="@point.SceneID"
class="metric-point"
style="--metric-height:@point.Percent%"
title="Scene @point.SceneNumber: @point.SceneTitle - @point.Value">
<span>@point.Value</span>
</a>
}
</div>
</div>
}
</div>
</details>
}
</div>
</section>
</main>
@if (Model.SelectedScene is not null)

View File

@ -754,6 +754,58 @@ body.timeline-inspector-resizing {
background: rgba(255, 255, 255, 0.45);
}
.metric-timeline-board {
display: grid;
gap: 8px;
width: max-content;
min-width: 100%;
margin-top: 14px;
border-top: 1px solid var(--plotline-line);
padding-top: 14px;
}
.metric-shape-controls {
position: sticky;
left: 0;
z-index: 6;
display: grid;
gap: 8px;
width: calc(var(--timeline-label-width) + (var(--scene-count) * var(--timeline-scene-width)) + (var(--scene-count) * var(--timeline-grid-gap)));
min-width: 100%;
border-radius: 8px;
padding: 10px;
background: rgba(255, 255, 255, 0.88);
}
.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)));
gap: var(--timeline-grid-gap);
align-items: stretch;
}
.metric-lane-label {
position: sticky;
left: 0;
z-index: 12;
display: grid;
align-content: start;
gap: 4px;
min-height: 320px;
border: 1px solid rgba(47, 111, 99, 0.18);
border-radius: 8px;
padding: 10px;
color: var(--plotline-accent-dark);
background: #fff;
box-shadow: 0 6px 14px rgba(30, 37, 43, 0.04);
}
.metric-lane-label span {
color: var(--plotline-muted);
font-size: 0.78rem;
font-weight: 600;
}
.colour-swatch {
display: inline-block;
width: 18px;
@ -774,6 +826,7 @@ body.timeline-inspector-resizing {
}
.lane-board-heading,
.metric-lane-board-heading,
.plot-lane-board-heading {
position: sticky;
left: 0;
@ -794,6 +847,7 @@ body.timeline-inspector-resizing {
}
.lane-board-heading small,
.metric-lane-board-heading small,
.plot-lane-board-heading small {
color: var(--plotline-muted);
font-weight: 500;
@ -1841,11 +1895,36 @@ body.timeline-inspector-resizing {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.92), rgba(247, 245, 240, 0.76));
}
.metric-timeline-frame {
width: calc((var(--scene-count) * var(--timeline-scene-width)) + ((var(--scene-count) - 1) * var(--timeline-grid-gap)));
}
.metric-chart-frame canvas {
width: 100%;
height: 300px;
}
.metric-timeline-board .metric-graph-stack {
width: max-content;
min-width: 100%;
}
.metric-timeline-board .metric-graph-row {
grid-template-columns: var(--timeline-label-width) repeat(var(--scene-count), var(--timeline-scene-width));
gap: var(--timeline-grid-gap);
min-width: 100%;
}
.metric-timeline-board .metric-graph-points {
display: contents;
}
.metric-timeline-board .metric-point {
border: 1px solid rgba(47, 111, 99, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.62);
}
.metric-raw-details {
border-top: 1px solid var(--plotline-line);
padding-top: 8px;

View File

@ -278,6 +278,14 @@
];
const globalDefaultMetrics = new Set(["Overall Intensity", "Tension", "Emotional Weight", "Action"]);
const metricCharts = [];
const resizeMetricCharts = () => {
window.requestAnimationFrame(() => {
metricCharts.forEach((chart) => chart.resize());
});
};
window.PlotLineMetricCharts = { resizeAll: resizeMetricCharts };
window.addEventListener("plotline:timeline-layout-changed", resizeMetricCharts);
const chapterGuidePlugin = {
id: "plotlineChapterGuides",
@ -300,11 +308,12 @@
chapterStarts.push({ scene, index });
}
});
const labelInterval = chapterStarts.length > 40 ? 5 : chapterStarts.length > 24 ? 4 : chapterStarts.length > 14 ? 2 : 1;
const realChapterStarts = chapterStarts.filter((entry) => !entry.scene.isPlaceholder);
const labelInterval = realChapterStarts.length > 40 ? 5 : realChapterStarts.length > 24 ? 4 : realChapterStarts.length > 14 ? 2 : 1;
chapterStarts.forEach((entry, chapterIndex) => {
realChapterStarts.forEach((entry, chapterIndex) => {
const { scene, index } = entry;
const x = scales.x.getPixelForValue(index);
const x = scales.x.getPixelForValue(index + 0.5);
ctx.beginPath();
ctx.moveTo(x, chartArea.top);
ctx.lineTo(x, chartArea.bottom);
@ -312,7 +321,7 @@
const chapterNumber = Number.parseInt(String(scene.chapterLabel).replace(/[^0-9]/g, ""), 10);
const shouldLabel = chapterIndex === 0 ||
chapterIndex === chapterStarts.length - 1 ||
chapterIndex === realChapterStarts.length - 1 ||
labelInterval === 1 ||
(Number.isFinite(chapterNumber) && chapterNumber % labelInterval === 0);
if (shouldLabel) {
@ -351,13 +360,17 @@
}
const defaultMetrics = new Set(payload.defaultMetrics?.length ? payload.defaultMetrics : globalDefaultMetrics);
const labels = payload.scenes.map((scene) => scene.axisLabel);
const columnCount = payload.scenes.length;
const tickLabelInterval = columnCount > 80 ? 10 : columnCount > 40 ? 5 : columnCount > 24 ? 4 : columnCount > 14 ? 2 : 1;
const datasets = payload.metrics.map((metric, metricIndex) => {
const pointMap = new Map(metric.points.map((point) => [point.sceneId, point.value]));
const colour = colours[metricIndex % colours.length];
return {
label: metric.metricName,
data: payload.scenes.map((scene) => pointMap.get(scene.sceneId) ?? null),
data: payload.scenes.map((scene, columnIndex) => {
const value = pointMap.get(scene.sceneId);
return value == null ? { x: columnIndex + 0.5, y: null } : { x: columnIndex + 0.5, y: value };
}),
borderColor: colour,
backgroundColor: colour,
pointBackgroundColor: colour,
@ -388,7 +401,7 @@
const chart = new Chart(canvas, {
type: "line",
data: { labels, datasets },
data: { datasets },
options: {
responsive: true,
maintainAspectRatio: false,
@ -401,10 +414,29 @@
grid: { color: "rgba(82, 75, 65, 0.10)" }
},
x: {
type: "linear",
min: 0,
max: columnCount,
bounds: "ticks",
ticks: {
maxRotation: 0,
autoSkip: true,
maxTicksLimit: 16
autoSkip: false,
maxTicksLimit: 16,
callback(value) {
const columnIndex = Math.floor(Number(value));
if (columnIndex < 0 || columnIndex >= columnCount) {
return "";
}
const scene = payload.scenes[columnIndex];
if (columnIndex !== 0 && columnIndex !== columnCount - 1 && columnIndex % tickLabelInterval !== 0) {
return "";
}
return scene?.isPlaceholder ? "" : scene?.axisLabel || "";
}
},
afterBuildTicks(scale) {
scale.ticks = payload.scenes.map((_, columnIndex) => ({ value: columnIndex + 0.5 }));
},
grid: { display: false }
}
@ -415,14 +447,19 @@
tooltip: {
callbacks: {
title(items) {
const scene = payload.scenes[items[0].dataIndex];
const columnIndex = Math.floor(items[0].parsed.x);
const scene = payload.scenes[columnIndex];
if (scene.isPlaceholder) {
return scene.sceneTitle || "No scene";
}
return `${scene.chapterLabel}, Scene ${scene.sceneNumber}: ${scene.sceneTitle}`;
},
label(item) {
return `${item.dataset.label}: ${item.formattedValue}`;
},
afterBody(items) {
const scene = payload.scenes[items[0].dataIndex];
const columnIndex = Math.floor(items[0].parsed.x);
const scene = payload.scenes[columnIndex];
const lines = [];
if (scene.characterPresence) {
lines.push(scene.characterPresence);
@ -448,12 +485,21 @@
return;
}
const scene = payload.scenes[points[0].index];
const point = chart.data.datasets[points[0].datasetIndex]?.data?.[points[0].index];
const columnIndex = point ? Math.floor(point.x) : points[0].index;
const scene = payload.scenes[columnIndex];
if (scene?.timelineUrl) {
window.PlotLineTimelineScroll?.save();
window.location.href = scene.timelineUrl;
}
});
metricCharts.push(chart);
const resizeObserver = window.ResizeObserver ? new ResizeObserver(() => chart.resize()) : null;
if (resizeObserver) {
resizeObserver.observe(root);
resizeObserver.observe(root.querySelector(".metric-chart-frame") || root);
}
});
})();
@ -603,6 +649,7 @@
if (width > 0) {
localStorage.setItem(drawerWidthKey, String(width));
}
window.dispatchEvent(new Event("plotline:timeline-layout-changed"));
inspectorResizer.removeEventListener("pointermove", resize);
inspectorResizer.removeEventListener("pointerup", finish);
inspectorResizer.removeEventListener("pointercancel", finish);
@ -767,6 +814,7 @@
}
setCollapsed(id, nextCollapsed);
saveCollapsed();
window.dispatchEvent(new Event("plotline:timeline-layout-changed"));
});
});