Implemented the requested revision to Timeline metric curve editing.
This commit is contained in:
parent
5f15c19a20
commit
c130879b27
30
PlotLine/Controllers/SceneMetricsController.cs
Normal file
30
PlotLine/Controllers/SceneMetricsController.cs
Normal 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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>();
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user