Changed metric graph to align with scenes.
This commit is contained in:
parent
b5a6de4fb2
commit
2ad5865c58
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user