diff --git a/PlotLine/Views/Timeline/Index.cshtml b/PlotLine/Views/Timeline/Index.cshtml
index 1775ef6..246cb93 100644
--- a/PlotLine/Views/Timeline/Index.cshtml
+++ b/PlotLine/Views/Timeline/Index.cshtml
@@ -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
+
+
+ Metric Shape
+ @Model.OrderedScenes.Count scene@(Model.OrderedScenes.Count == 1 ? "" : "s")
+
+
+ @if (!Model.MetricGraphs.Any() || !Model.OrderedScenes.Any())
+ {
+
+ Add scenes to begin seeing emotional and pacing shape.
+
+ }
+ else
+ {
+
+
+
Compare selected metrics across the same scene timeline. Click a point to focus that scene.
+
+
+
+
+ Metric Shape
+ Scene pacing
+
+
+
+
Metric chart could not load. The raw metric rows are still available below.
+
+
+
+
+
+ Show raw metric rows
+
+ @foreach (var graph in Model.MetricGraphs)
+ {
+
+
@graph.MetricName
+
+ @foreach (var column in timelineColumns)
+ {
+ if (column.Scene is null)
+ {
+
No value
+ continue;
+ }
+
+ var point = graph.Points.FirstOrDefault(x => x.SceneID == column.Scene.SceneID);
+ if (point is null)
+ {
+
No value
+ continue;
+ }
+
+
+ @point.Value
+
+ }
+
+
+ }
+
+
+ }
+
+
@if (Model.PlotLanes.Any() && Model.OrderedScenes.Any())
{
@@ -908,73 +980,6 @@ else
}
-
-
-
-
-
Pacing
-
Metric shape
-
-
@Model.OrderedScenes.Count scene@(Model.OrderedScenes.Count == 1 ? "" : "s")
-
-
- @if (!Model.MetricGraphs.Any() || !Model.OrderedScenes.Any())
- {
-
Add scenes to begin seeing emotional and pacing shape.
- }
- else
- {
-
-
Compare selected metrics across the same scene timeline. Click a point to focus that scene.
-
-
-
-
Metric chart could not load. The raw metric rows are still available below.
-
-
-
-
- Show raw metric rows
-
- @foreach (var graph in Model.MetricGraphs)
- {
-
-
@graph.MetricName
-
- @foreach (var column in timelineColumns)
- {
- if (column.Scene is null)
- {
-
No value
- continue;
- }
-
- var point = graph.Points.FirstOrDefault(x => x.SceneID == column.Scene.SceneID);
- if (point is null)
- {
-
No value
- continue;
- }
-
-
- @point.Value
-
- }
-
-
- }
-
-
- }
-
-
@if (Model.SelectedScene is not null)
diff --git a/PlotLine/wwwroot/css/site.css b/PlotLine/wwwroot/css/site.css
index 8590088..bc9c032 100644
--- a/PlotLine/wwwroot/css/site.css
+++ b/PlotLine/wwwroot/css/site.css
@@ -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;
diff --git a/PlotLine/wwwroot/js/site.js b/PlotLine/wwwroot/js/site.js
index 13011b7..6589664 100644
--- a/PlotLine/wwwroot/js/site.js
+++ b/PlotLine/wwwroot/js/site.js
@@ -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"));
});
});