From c130879b2702fecffdf9fa8d65dc63802d76d22b Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Tue, 9 Jun 2026 19:36:28 +0100 Subject: [PATCH] Implemented the requested revision to Timeline metric curve editing. --- .../Controllers/SceneMetricsController.cs | 30 +++ PlotLine/Data/Repositories.cs | 18 ++ PlotLine/Program.cs | 1 + PlotLine/Services/CoreServices.cs | 46 +++++ PlotLine/ViewModels/CoreViewModels.cs | 10 + PlotLine/Views/Timeline/Index.cshtml | 9 +- PlotLine/wwwroot/css/site.css | 35 ++++ PlotLine/wwwroot/js/site.js | 193 +++++++++++++++++- 8 files changed, 340 insertions(+), 2 deletions(-) create mode 100644 PlotLine/Controllers/SceneMetricsController.cs diff --git a/PlotLine/Controllers/SceneMetricsController.cs b/PlotLine/Controllers/SceneMetricsController.cs new file mode 100644 index 0000000..16dd7cf --- /dev/null +++ b/PlotLine/Controllers/SceneMetricsController.cs @@ -0,0 +1,30 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using PlotLine.Services; +using PlotLine.ViewModels; + +namespace PlotLine.Controllers; + +[Authorize] +public sealed class SceneMetricsController(ISceneMetricValueService sceneMetrics) : Controller +{ + [HttpPost] + [ValidateAntiForgeryToken] + public async Task UpdateSceneMetricValue([FromBody] SceneMetricValueUpdateViewModel? model) + { + if (model is null || model.SceneID <= 0 || (model.MetricTypeID <= 0 && model.MetricID <= 0)) + { + return BadRequest(new { success = false, message = "Missing scene or metric details." }); + } + + try + { + var value = await sceneMetrics.UpdateSceneMetricValueAsync(model); + return Json(new { success = true, value }); + } + catch (InvalidOperationException ex) + { + return BadRequest(new { success = false, message = ex.Message }); + } + } +} diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 6392d66..8a21be7 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -63,6 +63,7 @@ public interface ISceneRepository Task SaveAsync(Scene scene); Task SavePurposesAsync(int sceneId, IEnumerable purposeIds); Task SaveMetricValuesAsync(int sceneId, IEnumerable metricValues); + Task SaveMetricValueAsync(int sceneId, int metricTypeId, int value); Task ArchiveAsync(int sceneId); Task MoveAsync(int sceneId, string direction); Task> ListChapterOptionsAsync(int projectId); @@ -1528,6 +1529,23 @@ public sealed class SceneRepository(ISqlConnectionFactory connectionFactory) : I } } + public async Task SaveMetricValueAsync(int sceneId, int metricTypeId, int value) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + """ + MERGE dbo.SceneMetricValues AS target + USING (SELECT @SceneID AS SceneID, @MetricTypeID AS MetricTypeID) AS source + ON target.SceneID = source.SceneID AND target.MetricTypeID = source.MetricTypeID + WHEN MATCHED THEN + UPDATE SET Value = @Value + WHEN NOT MATCHED THEN + INSERT (SceneID, MetricTypeID, Value, Notes) + VALUES (@SceneID, @MetricTypeID, @Value, NULL); + """, + new { SceneID = sceneId, MetricTypeID = metricTypeId, Value = value }); + } + public async Task ArchiveAsync(int sceneId) { using var connection = connectionFactory.CreateConnection(); diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index eedcf24..5148abc 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -116,6 +116,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index d4f5f74..7ae181b 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -218,6 +218,11 @@ public interface ISceneMetricTypeService Task RemoveUnusedAsync(int projectId, int metricTypeId); } +public interface ISceneMetricValueService +{ + Task UpdateSceneMetricValueAsync(SceneMetricValueUpdateViewModel model); +} + public interface IAnalyticsService { Task GetStoryHealthAsync(int projectId, int? bookId, decimal? startChapterNumber, decimal? endChapterNumber, decimal? startSceneNumber, decimal? endSceneNumber); @@ -1862,6 +1867,8 @@ public sealed class TimelineService( { MetricTypeID = metric.MetricTypeID, MetricName = metric.MetricName, + MinValue = metric.MinValue, + MaxValue = metric.MaxValue, Points = orderedScenes.Select(scene => { var value = metric.DefaultValue; @@ -2489,6 +2496,45 @@ public sealed class SceneMetricTypeService(IProjectRepository projects, ISceneMe public Task RemoveUnusedAsync(int projectId, int metricTypeId) => metricTypes.RemoveUnusedAsync(metricTypeId, projectId); } +public sealed class SceneMetricValueService( + ISceneRepository scenes, + IChapterRepository chapters, + IBookRepository books, + ISceneMetricTypeRepository metricTypes) : ISceneMetricValueService +{ + public async Task UpdateSceneMetricValueAsync(SceneMetricValueUpdateViewModel model) + { + var metricTypeId = model.MetricTypeID > 0 ? model.MetricTypeID : model.MetricID; + if (model.SceneID <= 0 || metricTypeId <= 0) + { + throw new InvalidOperationException("Missing scene or metric details."); + } + + var scene = await scenes.GetAsync(model.SceneID); + if (scene is null) + { + throw new InvalidOperationException("Scene could not be found."); + } + + var chapter = await chapters.GetAsync(scene.ChapterID); + var book = chapter is null ? null : await books.GetAsync(chapter.BookID); + if (book is null) + { + throw new InvalidOperationException("Scene project could not be found."); + } + + var metric = await metricTypes.GetAsync(metricTypeId, book.ProjectID); + if (metric is null || !metric.IsActive || !metric.IsEnabledForProject) + { + throw new InvalidOperationException("Metric is not available for this project."); + } + + var value = Math.Clamp(model.Value, metric.MinValue, metric.MaxValue); + await scenes.SaveMetricValueAsync(model.SceneID, metricTypeId, value); + return value; + } +} + public sealed class AnalyticsService(IProjectRepository projects, IBookRepository books, IAnalyticsRepository analytics) : IAnalyticsService { public async Task GetStoryHealthAsync(int projectId, int? bookId, decimal? startChapterNumber, decimal? endChapterNumber, decimal? startSceneNumber, decimal? endSceneNumber) diff --git a/PlotLine/ViewModels/CoreViewModels.cs b/PlotLine/ViewModels/CoreViewModels.cs index 8e7d3f9..e98d18b 100644 --- a/PlotLine/ViewModels/CoreViewModels.cs +++ b/PlotLine/ViewModels/CoreViewModels.cs @@ -669,9 +669,19 @@ public sealed class MetricGraphViewModel { public int MetricTypeID { get; set; } public string MetricName { get; set; } = string.Empty; + public int MinValue { get; set; } + public int MaxValue { get; set; } public IReadOnlyList Points { get; set; } = []; } +public sealed class SceneMetricValueUpdateViewModel +{ + public int SceneID { get; set; } + public int MetricTypeID { get; set; } + public int MetricID { get; set; } + public int Value { get; set; } +} + public sealed class MetricGraphPointViewModel { public int SceneID { get; set; } diff --git a/PlotLine/Views/Timeline/Index.cshtml b/PlotLine/Views/Timeline/Index.cshtml index ded8191..bdb82c7 100644 --- a/PlotLine/Views/Timeline/Index.cshtml +++ b/PlotLine/Views/Timeline/Index.cshtml @@ -45,7 +45,10 @@ scenes = timelineMetricColumns, metrics = Model.MetricGraphs.Select(graph => new { + metricTypeId = graph.MetricTypeID, metricName = graph.MetricName, + minValue = graph.MinValue, + maxValue = graph.MaxValue, points = graph.Points.Select(point => new { sceneId = point.SceneID, value = point.Value }) }) }; @@ -748,10 +751,14 @@ else } else { -
+

Compare selected metrics across the same scene timeline. Click a point to focus that scene.

+
+ @Html.AntiForgeryToken() + Show only one metric line to edit its curve. +
diff --git a/PlotLine/wwwroot/css/site.css b/PlotLine/wwwroot/css/site.css index db6b659..eb3162d 100644 --- a/PlotLine/wwwroot/css/site.css +++ b/PlotLine/wwwroot/css/site.css @@ -1916,6 +1916,41 @@ body.timeline-inspector-resizing { accent-color: var(--metric-colour); } +.timeline-metric-edit-toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: end; +} + +.timeline-metric-edit-select { + display: grid; + gap: 3px; + min-width: 180px; + color: var(--plotline-muted); + font-size: 0.78rem; + font-weight: 800; +} + +.timeline-metric-edit-status { + align-self: center; + border: 1px solid rgba(47, 111, 99, 0.18); + border-radius: 999px; + padding: 5px 9px; + color: var(--plotline-muted); + font-size: 0.78rem; + font-weight: 800; + background: rgba(255, 255, 255, 0.72); +} + +.timeline-metric-edit-hint { + font-size: 0.82rem; +} + +.metric-chart-frame canvas.metric-curve-editing { + cursor: ns-resize; +} + .metric-chart-frame { position: relative; min-height: 320px; diff --git a/PlotLine/wwwroot/js/site.js b/PlotLine/wwwroot/js/site.js index 5361d74..f1a87cc 100644 --- a/PlotLine/wwwroot/js/site.js +++ b/PlotLine/wwwroot/js/site.js @@ -715,6 +715,9 @@ const canvas = root.querySelector("canvas"); const toggleHost = root.querySelector("[data-metric-toggles]"); const fallback = root.querySelector("[data-chart-fallback]"); + const editToolbar = root.querySelector("[data-timeline-metric-edit-toolbar]"); + const editStatus = root.querySelector("[data-edit-metric-status]"); + const canEditTimelineMetric = root.hasAttribute("data-timeline-metric-editor") && editToolbar && editStatus; if (!payloadScript || !canvas) { return; } @@ -740,9 +743,14 @@ const colour = colours[metricIndex % colours.length]; return { label: metric.metricName, + metricTypeId: metric.metricTypeId, + minValue: Number.isFinite(metric.minValue) ? metric.minValue : 1, + maxValue: Number.isFinite(metric.maxValue) ? metric.maxValue : 10, 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 }; + return value == null + ? { x: columnIndex + 0.5, y: null, sceneId: scene.sceneId } + : { x: columnIndex + 0.5, y: value, sceneId: scene.sceneId }; }), borderColor: colour, backgroundColor: colour, @@ -758,6 +766,49 @@ }; }); + let activeMetricDrag = null; + let suppressMetricClick = false; + + const setMetricEditStatus = (message) => { + if (editStatus) { + editStatus.textContent = message; + } + }; + + const getVisibleDatasetIndexes = () => datasets + .map((_, index) => index) + .filter((index) => chart?.isDatasetVisible(index)); + + const getEditableDatasetIndex = () => { + const visibleIndexes = getVisibleDatasetIndexes(); + return visibleIndexes.length === 1 ? visibleIndexes[0] : -1; + }; + + const updateMetricEditUi = () => { + if (!canEditTimelineMetric) { + return; + } + + const editableIndex = getEditableDatasetIndex(); + datasets.forEach((dataset, index) => { + dataset.pointRadius = index === editableIndex ? 5 : 3; + dataset.pointHoverRadius = index === editableIndex ? 7 : 5; + dataset.borderWidth = index === editableIndex ? 3 : 2; + }); + const visibleIndexes = getVisibleDatasetIndexes(); + const canDrag = editableIndex >= 0; + canvas.classList.toggle("metric-curve-editing", canDrag); + editStatus.classList.toggle("d-none", visibleIndexes.length === 0); + if (visibleIndexes.length === 0) { + setMetricEditStatus(""); + } else if (canDrag) { + setMetricEditStatus("Drag points up or down to edit this metric."); + } else { + setMetricEditStatus("Show only one metric line to edit its curve."); + } + chart?.update(); + }; + toggleHost.innerHTML = ""; datasets.forEach((dataset, index) => { const id = `metric-toggle-${Math.random().toString(36).slice(2)}-${index}`; @@ -768,6 +819,7 @@ toggleHost.appendChild(label); label.querySelector("input").addEventListener("change", (event) => { chart.setDatasetVisibility(index, event.target.checked); + updateMetricEditUi(); chart.update(); }); }); @@ -852,7 +904,146 @@ } }); + const getMetricDragPoint = (event) => { + if (!canEditTimelineMetric) { + return null; + } + + const editableIndex = getEditableDatasetIndex(); + if (editableIndex < 0) { + return null; + } + + const points = chart.getElementsAtEventForMode(event, "nearest", { intersect: true }, true); + const match = points.find((point) => point.datasetIndex === editableIndex); + if (!match) { + return null; + } + + const dataset = chart.data.datasets[match.datasetIndex]; + const point = dataset?.data?.[match.index]; + if (!point?.sceneId || point.y == null) { + return null; + } + + return { + datasetIndex: match.datasetIndex, + pointIndex: match.index, + previousValue: point.y + }; + }; + + const dragValueFromEvent = (event, dataset) => { + const rawValue = chart.scales.y.getValueForPixel(event.offsetY); + const roundedValue = Math.round(rawValue); + return Math.max(dataset.minValue, Math.min(dataset.maxValue, roundedValue)); + }; + + const saveMetricDrag = async (dragState) => { + const dataset = chart.data.datasets[dragState.datasetIndex]; + const point = dataset?.data?.[dragState.pointIndex]; + if (!dataset || !point?.sceneId) { + return; + } + + const token = editToolbar?.querySelector("input[name='__RequestVerificationToken']")?.value || ""; + setMetricEditStatus("Saving..."); + try { + const response = await fetch(root.dataset.updateUrl || "/SceneMetrics/UpdateSceneMetricValue", { + method: "POST", + headers: { + "Content-Type": "application/json", + "RequestVerificationToken": token + }, + body: JSON.stringify({ + sceneId: Number(point.sceneId), + metricId: Number(dataset.metricTypeId), + value: Number(point.y) + }) + }); + + const result = await response.json().catch(() => ({})); + if (!response.ok || !result.success) { + throw new Error(result.message || "Save failed"); + } + + point.y = result.value; + setMetricEditStatus("Saved"); + chart.update(); + } catch { + point.y = dragState.previousValue; + setMetricEditStatus("Save failed"); + chart.update(); + } + }; + + if (canEditTimelineMetric) { + updateMetricEditUi(); + + canvas.addEventListener("pointerdown", (event) => { + activeMetricDrag = getMetricDragPoint(event); + if (!activeMetricDrag) { + return; + } + + event.preventDefault(); + canvas.setPointerCapture?.(event.pointerId); + suppressMetricClick = true; + }); + + canvas.addEventListener("pointermove", (event) => { + if (!activeMetricDrag) { + return; + } + + const dataset = chart.data.datasets[activeMetricDrag.datasetIndex]; + const point = dataset?.data?.[activeMetricDrag.pointIndex]; + if (!dataset || !point) { + return; + } + + point.y = dragValueFromEvent(event, dataset); + chart.update("none"); + }); + + canvas.addEventListener("pointerup", async (event) => { + if (!activeMetricDrag) { + return; + } + + const dragState = activeMetricDrag; + activeMetricDrag = null; + canvas.releasePointerCapture?.(event.pointerId); + await saveMetricDrag(dragState); + window.setTimeout(() => { + suppressMetricClick = false; + }, 0); + }); + + canvas.addEventListener("pointercancel", (event) => { + if (!activeMetricDrag) { + return; + } + + const dataset = chart.data.datasets[activeMetricDrag.datasetIndex]; + const point = dataset?.data?.[activeMetricDrag.pointIndex]; + if (point) { + point.y = activeMetricDrag.previousValue; + } + + activeMetricDrag = null; + canvas.releasePointerCapture?.(event.pointerId); + suppressMetricClick = false; + chart.update(); + }); + } + canvas.addEventListener("click", (event) => { + if (suppressMetricClick) { + event.preventDefault(); + return; + } + const points = chart.getElementsAtEventForMode(event, "nearest", { intersect: true }, true); if (!points.length) { return;