Implemented the requested revision to Timeline metric curve editing.

This commit is contained in:
Nick Beckley 2026-06-09 19:36:28 +01:00
parent 5f15c19a20
commit c130879b27
8 changed files with 340 additions and 2 deletions

View File

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

View File

@ -63,6 +63,7 @@ public interface ISceneRepository
Task<int> SaveAsync(Scene scene);
Task SavePurposesAsync(int sceneId, IEnumerable<int> purposeIds);
Task SaveMetricValuesAsync(int sceneId, IEnumerable<SceneMetricValue> metricValues);
Task SaveMetricValueAsync(int sceneId, int metricTypeId, int value);
Task ArchiveAsync(int sceneId);
Task MoveAsync(int sceneId, string direction);
Task<IReadOnlyList<ChapterOption>> 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();

View File

@ -116,6 +116,7 @@ public class Program
builder.Services.AddScoped<ISceneService, SceneService>();
builder.Services.AddScoped<ITimelineService, TimelineService>();
builder.Services.AddScoped<ISceneMetricTypeService, SceneMetricTypeService>();
builder.Services.AddScoped<ISceneMetricValueService, SceneMetricValueService>();
builder.Services.AddScoped<IPlotService, PlotService>();
builder.Services.AddScoped<IAssetService, AssetService>();
builder.Services.AddScoped<ICharacterService, CharacterService>();

View File

@ -218,6 +218,11 @@ public interface ISceneMetricTypeService
Task<bool> RemoveUnusedAsync(int projectId, int metricTypeId);
}
public interface ISceneMetricValueService
{
Task<int> UpdateSceneMetricValueAsync(SceneMetricValueUpdateViewModel model);
}
public interface IAnalyticsService
{
Task<AnalyticsViewModel?> 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<bool> 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<int> 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<AnalyticsViewModel?> GetStoryHealthAsync(int projectId, int? bookId, decimal? startChapterNumber, decimal? endChapterNumber, decimal? startSceneNumber, decimal? endSceneNumber)

View File

@ -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<MetricGraphPointViewModel> 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; }

View File

@ -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
{
<div class="metric-shape-chart metric-shape-timeline-chart" data-metric-shape-chart>
<div class="metric-shape-chart metric-shape-timeline-chart" data-metric-shape-chart data-timeline-metric-editor data-update-url="@Url.Action("UpdateSceneMetricValue", "SceneMetrics")">
<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 class="timeline-metric-edit-toolbar" data-timeline-metric-edit-toolbar>
@Html.AntiForgeryToken()
<span class="timeline-metric-edit-status" data-edit-metric-status>Show only one metric line to edit its curve.</span>
</div>
</div>
<div class="metric-timeline-row">
<div class="metric-lane-label">

View File

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

View File

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