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<int> SaveAsync(Scene scene);
|
||||||
Task SavePurposesAsync(int sceneId, IEnumerable<int> purposeIds);
|
Task SavePurposesAsync(int sceneId, IEnumerable<int> purposeIds);
|
||||||
Task SaveMetricValuesAsync(int sceneId, IEnumerable<SceneMetricValue> metricValues);
|
Task SaveMetricValuesAsync(int sceneId, IEnumerable<SceneMetricValue> metricValues);
|
||||||
|
Task SaveMetricValueAsync(int sceneId, int metricTypeId, int value);
|
||||||
Task ArchiveAsync(int sceneId);
|
Task ArchiveAsync(int sceneId);
|
||||||
Task MoveAsync(int sceneId, string direction);
|
Task MoveAsync(int sceneId, string direction);
|
||||||
Task<IReadOnlyList<ChapterOption>> ListChapterOptionsAsync(int projectId);
|
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)
|
public async Task ArchiveAsync(int sceneId)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
|
|||||||
@ -116,6 +116,7 @@ public class Program
|
|||||||
builder.Services.AddScoped<ISceneService, SceneService>();
|
builder.Services.AddScoped<ISceneService, SceneService>();
|
||||||
builder.Services.AddScoped<ITimelineService, TimelineService>();
|
builder.Services.AddScoped<ITimelineService, TimelineService>();
|
||||||
builder.Services.AddScoped<ISceneMetricTypeService, SceneMetricTypeService>();
|
builder.Services.AddScoped<ISceneMetricTypeService, SceneMetricTypeService>();
|
||||||
|
builder.Services.AddScoped<ISceneMetricValueService, SceneMetricValueService>();
|
||||||
builder.Services.AddScoped<IPlotService, PlotService>();
|
builder.Services.AddScoped<IPlotService, PlotService>();
|
||||||
builder.Services.AddScoped<IAssetService, AssetService>();
|
builder.Services.AddScoped<IAssetService, AssetService>();
|
||||||
builder.Services.AddScoped<ICharacterService, CharacterService>();
|
builder.Services.AddScoped<ICharacterService, CharacterService>();
|
||||||
|
|||||||
@ -218,6 +218,11 @@ public interface ISceneMetricTypeService
|
|||||||
Task<bool> RemoveUnusedAsync(int projectId, int metricTypeId);
|
Task<bool> RemoveUnusedAsync(int projectId, int metricTypeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public interface ISceneMetricValueService
|
||||||
|
{
|
||||||
|
Task<int> UpdateSceneMetricValueAsync(SceneMetricValueUpdateViewModel model);
|
||||||
|
}
|
||||||
|
|
||||||
public interface IAnalyticsService
|
public interface IAnalyticsService
|
||||||
{
|
{
|
||||||
Task<AnalyticsViewModel?> GetStoryHealthAsync(int projectId, int? bookId, decimal? startChapterNumber, decimal? endChapterNumber, decimal? startSceneNumber, decimal? endSceneNumber);
|
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,
|
MetricTypeID = metric.MetricTypeID,
|
||||||
MetricName = metric.MetricName,
|
MetricName = metric.MetricName,
|
||||||
|
MinValue = metric.MinValue,
|
||||||
|
MaxValue = metric.MaxValue,
|
||||||
Points = orderedScenes.Select(scene =>
|
Points = orderedScenes.Select(scene =>
|
||||||
{
|
{
|
||||||
var value = metric.DefaultValue;
|
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 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 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)
|
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 int MetricTypeID { get; set; }
|
||||||
public string MetricName { get; set; } = string.Empty;
|
public string MetricName { get; set; } = string.Empty;
|
||||||
|
public int MinValue { get; set; }
|
||||||
|
public int MaxValue { get; set; }
|
||||||
public IReadOnlyList<MetricGraphPointViewModel> Points { 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 sealed class MetricGraphPointViewModel
|
||||||
{
|
{
|
||||||
public int SceneID { get; set; }
|
public int SceneID { get; set; }
|
||||||
|
|||||||
@ -45,7 +45,10 @@
|
|||||||
scenes = timelineMetricColumns,
|
scenes = timelineMetricColumns,
|
||||||
metrics = Model.MetricGraphs.Select(graph => new
|
metrics = Model.MetricGraphs.Select(graph => new
|
||||||
{
|
{
|
||||||
|
metricTypeId = graph.MetricTypeID,
|
||||||
metricName = graph.MetricName,
|
metricName = graph.MetricName,
|
||||||
|
minValue = graph.MinValue,
|
||||||
|
maxValue = graph.MaxValue,
|
||||||
points = graph.Points.Select(point => new { sceneId = point.SceneID, value = point.Value })
|
points = graph.Points.Select(point => new { sceneId = point.SceneID, value = point.Value })
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@ -748,10 +751,14 @@ else
|
|||||||
}
|
}
|
||||||
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">
|
<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>
|
<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-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>
|
||||||
<div class="metric-timeline-row">
|
<div class="metric-timeline-row">
|
||||||
<div class="metric-lane-label">
|
<div class="metric-lane-label">
|
||||||
|
|||||||
@ -1916,6 +1916,41 @@ body.timeline-inspector-resizing {
|
|||||||
accent-color: var(--metric-colour);
|
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 {
|
.metric-chart-frame {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 320px;
|
min-height: 320px;
|
||||||
|
|||||||
@ -715,6 +715,9 @@
|
|||||||
const canvas = root.querySelector("canvas");
|
const canvas = root.querySelector("canvas");
|
||||||
const toggleHost = root.querySelector("[data-metric-toggles]");
|
const toggleHost = root.querySelector("[data-metric-toggles]");
|
||||||
const fallback = root.querySelector("[data-chart-fallback]");
|
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) {
|
if (!payloadScript || !canvas) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -740,9 +743,14 @@
|
|||||||
const colour = colours[metricIndex % colours.length];
|
const colour = colours[metricIndex % colours.length];
|
||||||
return {
|
return {
|
||||||
label: metric.metricName,
|
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) => {
|
data: payload.scenes.map((scene, columnIndex) => {
|
||||||
const value = pointMap.get(scene.sceneId);
|
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,
|
borderColor: colour,
|
||||||
backgroundColor: 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 = "";
|
toggleHost.innerHTML = "";
|
||||||
datasets.forEach((dataset, index) => {
|
datasets.forEach((dataset, index) => {
|
||||||
const id = `metric-toggle-${Math.random().toString(36).slice(2)}-${index}`;
|
const id = `metric-toggle-${Math.random().toString(36).slice(2)}-${index}`;
|
||||||
@ -768,6 +819,7 @@
|
|||||||
toggleHost.appendChild(label);
|
toggleHost.appendChild(label);
|
||||||
label.querySelector("input").addEventListener("change", (event) => {
|
label.querySelector("input").addEventListener("change", (event) => {
|
||||||
chart.setDatasetVisibility(index, event.target.checked);
|
chart.setDatasetVisibility(index, event.target.checked);
|
||||||
|
updateMetricEditUi();
|
||||||
chart.update();
|
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) => {
|
canvas.addEventListener("click", (event) => {
|
||||||
|
if (suppressMetricClick) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const points = chart.getElementsAtEventForMode(event, "nearest", { intersect: true }, true);
|
const points = chart.getElementsAtEventForMode(event, "nearest", { intersect: true }, true);
|
||||||
if (!points.length) {
|
if (!points.length) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user