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 +
+
+ + @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

-

-
- @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")); }); });