mirror of
https://github.com/BetterSEQTA/BetterSEQTA-Plus.git
synced 2026-06-13 23:24:40 +00:00
feat: grade forecast
This commit is contained in:
@@ -1,388 +1,335 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
|
||||||
import * as Chart from "./chart/index";
|
import * as Chart from "./chart/index";
|
||||||
|
import { scaleLinear } from "d3-scale";
|
||||||
import { scaleUtc, scaleLinear } from "d3-scale";
|
import { Area, AreaChart, ChartClipPath, Spline } from "layerchart";
|
||||||
|
|
||||||
import { Area, AreaChart, ChartClipPath } from "layerchart";
|
|
||||||
|
|
||||||
import { curveNatural } from "d3-shape";
|
import { curveNatural } from "d3-shape";
|
||||||
|
|
||||||
import { cubicInOut } from "svelte/easing";
|
import { cubicInOut } from "svelte/easing";
|
||||||
|
|
||||||
import type { Assessment } from "./types";
|
import type { Assessment } from "./types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
||||||
buildGradeTrendChart,
|
buildGradeTrendChart,
|
||||||
|
|
||||||
getTimeRangeLabel,
|
getTimeRangeLabel,
|
||||||
|
|
||||||
type TimeRange,
|
type TimeRange,
|
||||||
|
|
||||||
} from "./timeRange";
|
} from "./timeRange";
|
||||||
|
import { computeGradeForecast, aggregateToMonthlyPoints } from "./utils/gradePrediction";
|
||||||
|
import PredictionMonthsSlider from "./PredictionMonthsSlider.svelte";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
|
|
||||||
data: Assessment[];
|
data: Assessment[];
|
||||||
|
|
||||||
timeRange: TimeRange;
|
timeRange: TimeRange;
|
||||||
|
|
||||||
showSubjectTrends?: boolean;
|
showSubjectTrends?: boolean;
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
let { data, timeRange, showSubjectTrends = false }: Props = $props();
|
let { data, timeRange, showSubjectTrends = false }: Props = $props();
|
||||||
|
|
||||||
|
let showPrediction = $state(false);
|
||||||
|
let predictionMonths = $state(3);
|
||||||
|
|
||||||
const chartUid = `area-${Math.random().toString(36).slice(2, 9)}`;
|
const chartUid = `area-${Math.random().toString(36).slice(2, 9)}`;
|
||||||
|
|
||||||
|
const chartResult = $derived.by(() =>
|
||||||
|
|
||||||
const chartResult = $derived(() =>
|
|
||||||
|
|
||||||
buildGradeTrendChart(data, timeRange, {
|
buildGradeTrendChart(data, timeRange, {
|
||||||
|
|
||||||
showPerSubject: showSubjectTrends,
|
showPerSubject: showSubjectTrends,
|
||||||
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const historicalData = $derived(chartResult.points);
|
||||||
|
const chartSeries = $derived(chartResult.series);
|
||||||
|
const accentColor = $derived(chartResult.accentColor);
|
||||||
|
|
||||||
|
const forecast = $derived.by(() => {
|
||||||
const filteredData = $derived(() => chartResult().points);
|
if (!showPrediction) return null;
|
||||||
|
const points = aggregateToMonthlyPoints(
|
||||||
const chartSeries = $derived(() => chartResult().series);
|
historicalData
|
||||||
|
.filter((p) => !Number.isNaN(p.average))
|
||||||
const accentColor = $derived(() => chartResult().accentColor);
|
.map((p) => ({ date: p.date, average: p.average })),
|
||||||
|
);
|
||||||
|
return computeGradeForecast(points, predictionMonths);
|
||||||
|
|
||||||
const chartConfig = $derived(() => {
|
|
||||||
|
|
||||||
const config: Chart.ChartConfig = {};
|
|
||||||
|
|
||||||
for (const s of chartSeries()) {
|
|
||||||
|
|
||||||
config[s.key] = { label: s.label, color: s.color };
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
return config;
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/** Bridge point + future months — separate from historical so the main line stays intact. */
|
||||||
|
const forecastLineData = $derived.by(() => {
|
||||||
|
if (!showPrediction || !forecast) return [];
|
||||||
|
|
||||||
|
const hist = historicalData.filter((p) => !Number.isNaN(p.average));
|
||||||
|
if (!hist.length) return [];
|
||||||
|
|
||||||
const yScale = $derived.by(() => {
|
const last = hist[hist.length - 1];
|
||||||
|
return [
|
||||||
const points = filteredData();
|
{ date: last.date, forecast: last.average },
|
||||||
|
...forecast.points.map((p) => ({ date: p.date, forecast: p.value })),
|
||||||
const series = chartSeries();
|
];
|
||||||
|
});
|
||||||
if (!points.length) return scaleLinear().domain([0, 100]);
|
|
||||||
|
|
||||||
const values: number[] = [];
|
|
||||||
|
|
||||||
for (const p of points) {
|
|
||||||
|
|
||||||
for (const s of series) {
|
|
||||||
|
|
||||||
const v = p[s.key];
|
|
||||||
|
|
||||||
if (typeof v === "number" && !Number.isNaN(v)) values.push(v);
|
|
||||||
|
|
||||||
|
/** Ghost future dates (null grades) extend the x domain without touching the historical line. */
|
||||||
|
const chartData = $derived.by(() => {
|
||||||
|
if (!showPrediction || forecastLineData.length <= 1) {
|
||||||
|
return historicalData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const futurePadding = forecastLineData.slice(1).map((p) => ({
|
||||||
|
date: p.date,
|
||||||
|
average: null,
|
||||||
|
count: 0,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return [...historicalData, ...futurePadding];
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartConfig = $derived.by(() => {
|
||||||
|
const config: Chart.ChartConfig = {};
|
||||||
|
for (const s of chartSeries) {
|
||||||
|
config[s.key] = { label: s.label, color: s.color };
|
||||||
|
}
|
||||||
|
if (showPrediction && forecastLineData.length > 1) {
|
||||||
|
config.forecast = {
|
||||||
|
label: "Forecast",
|
||||||
|
color: "var(--bsplus-analytics-forecast, var(--bsplus-analytics-accent))",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
const yScale = $derived.by(() => {
|
||||||
|
if (!historicalData.length) return scaleLinear().domain([0, 100]);
|
||||||
|
|
||||||
|
const values: number[] = [];
|
||||||
|
for (const p of historicalData) {
|
||||||
|
for (const s of chartSeries) {
|
||||||
|
const v = p[s.key];
|
||||||
|
if (typeof v === "number" && !Number.isNaN(v)) values.push(v);
|
||||||
|
}
|
||||||
|
if (typeof p.average === "number" && !Number.isNaN(p.average)) {
|
||||||
|
values.push(p.average);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const p of forecastLineData) {
|
||||||
|
if (typeof p.forecast === "number" && !Number.isNaN(p.forecast)) {
|
||||||
|
values.push(p.forecast);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!values.length) return scaleLinear().domain([0, 100]);
|
if (!values.length) return scaleLinear().domain([0, 100]);
|
||||||
|
|
||||||
const min = Math.max(0, Math.min(...values) - 8);
|
const min = Math.max(0, Math.min(...values) - 8);
|
||||||
|
|
||||||
const max = Math.min(100, Math.max(...values) + 8);
|
const max = Math.min(100, Math.max(...values) + 8);
|
||||||
|
|
||||||
return scaleLinear().domain([min, max]).nice();
|
return scaleLinear().domain([min, max]).nice();
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const trend = $derived.by(() => {
|
||||||
|
if (historicalData.length < 2) {
|
||||||
|
return { percentage: "0", direction: "neutral" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
const recent = historicalData.slice(-2);
|
||||||
const trend = $derived(() => {
|
|
||||||
|
|
||||||
const points = filteredData();
|
|
||||||
|
|
||||||
if (points.length < 2) return { percentage: "0", direction: "neutral" as const };
|
|
||||||
|
|
||||||
const recent = points.slice(-2);
|
|
||||||
|
|
||||||
const change = recent[1].average - recent[0].average;
|
const change = recent[1].average - recent[0].average;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
||||||
percentage: Math.abs(change).toFixed(1),
|
percentage: Math.abs(change).toFixed(1),
|
||||||
|
direction:
|
||||||
direction: change > 0 ? ("up" as const) : change < 0 ? ("down" as const) : ("neutral" as const),
|
change > 0 ? ("up" as const) : change < 0 ? ("down" as const) : ("neutral" as const),
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const areaSeries = $derived.by(() => {
|
||||||
|
const series = chartSeries.map((s) => ({
|
||||||
const areaSeries = $derived(() =>
|
|
||||||
|
|
||||||
chartSeries().map((s) => ({
|
|
||||||
|
|
||||||
key: s.key,
|
key: s.key,
|
||||||
|
|
||||||
label: s.label,
|
label: s.label,
|
||||||
|
|
||||||
color: s.color,
|
color: s.color,
|
||||||
|
}));
|
||||||
|
|
||||||
})),
|
if (showPrediction && forecastLineData.length > 1) {
|
||||||
|
series.push({
|
||||||
|
key: "forecast",
|
||||||
|
label: "Forecast",
|
||||||
|
color: "var(--bsplus-analytics-forecast, var(--bsplus-analytics-accent))",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return series;
|
||||||
|
});
|
||||||
|
|
||||||
|
const canForecast = $derived.by(() => {
|
||||||
|
const monthly = aggregateToMonthlyPoints(
|
||||||
|
historicalData
|
||||||
|
.filter((p) => !Number.isNaN(p.average))
|
||||||
|
.map((p) => ({ date: p.date, average: p.average })),
|
||||||
);
|
);
|
||||||
|
return monthly.length >= 3;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<article class="bsplus-analytics-card">
|
<article class="bsplus-analytics-card">
|
||||||
|
<header class="bsplus-analytics-card-header bsplus-analytics-card-header-split">
|
||||||
<header class="bsplus-analytics-card-header">
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
|
||||||
<h3 class="bsplus-analytics-card-title">Grade trends</h3>
|
<h3 class="bsplus-analytics-card-title">Grade trends</h3>
|
||||||
|
|
||||||
<p class="bsplus-analytics-card-desc">
|
<p class="bsplus-analytics-card-desc">
|
||||||
|
|
||||||
{#if showSubjectTrends}
|
{#if showSubjectTrends}
|
||||||
|
|
||||||
Overall and per-subject averages · {getTimeRangeLabel(timeRange)}
|
Overall and per-subject averages · {getTimeRangeLabel(timeRange)}
|
||||||
|
|
||||||
{:else}
|
{:else}
|
||||||
|
|
||||||
Average grades over time · {getTimeRangeLabel(timeRange)}
|
Average grades over time · {getTimeRangeLabel(timeRange)}
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="bsplus-analytics-card-controls bsplus-analytics-forecast-controls">
|
||||||
|
<label class="bsplus-analytics-checkbox bsplus-analytics-forecast-toggle">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
bind:checked={showPrediction}
|
||||||
|
disabled={!canForecast}
|
||||||
|
/>
|
||||||
|
<span>Grade forecast</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="bsplus-analytics-card-control bsplus-analytics-forecast-horizon">
|
||||||
|
<span class="bsplus-analytics-field-label">Months ahead</span>
|
||||||
|
<PredictionMonthsSlider bind:value={predictionMonths} disabled={!showPrediction} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="bsplus-analytics-card-body">
|
<div class="bsplus-analytics-card-body">
|
||||||
|
{#if historicalData.length > 0}
|
||||||
{#if filteredData().length > 0}
|
{#key `${showPrediction}-${predictionMonths}`}
|
||||||
|
<Chart.Container config={chartConfig} class="bsplus-chart-surface w-full">
|
||||||
<Chart.Container config={chartConfig()} class="bsplus-chart-surface w-full">
|
|
||||||
|
|
||||||
<AreaChart
|
<AreaChart
|
||||||
|
|
||||||
legend
|
legend
|
||||||
|
data={chartData}
|
||||||
data={filteredData()}
|
|
||||||
|
|
||||||
x="date"
|
x="date"
|
||||||
|
yScale={yScale}
|
||||||
xScale={scaleUtc()}
|
series={areaSeries}
|
||||||
|
|
||||||
yScale={yScale()}
|
|
||||||
|
|
||||||
series={areaSeries()}
|
|
||||||
|
|
||||||
props={{
|
props={{
|
||||||
|
|
||||||
area: {
|
area: {
|
||||||
|
|
||||||
curve: curveNatural,
|
curve: curveNatural,
|
||||||
|
|
||||||
"fill-opacity": showSubjectTrends ? 0.12 : 0.35,
|
"fill-opacity": showSubjectTrends ? 0.12 : 0.35,
|
||||||
|
|
||||||
line: { class: "stroke-2" },
|
line: { class: "stroke-2" },
|
||||||
|
|
||||||
motion: "tween",
|
motion: "tween",
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
xAxis: {
|
xAxis: {
|
||||||
|
|
||||||
ticks: timeRange === "7d" ? 7 : undefined,
|
ticks: timeRange === "7d" ? 7 : undefined,
|
||||||
|
|
||||||
format: (v: Date) =>
|
format: (v: Date) =>
|
||||||
|
|
||||||
v.toLocaleDateString("en-US", {
|
v.toLocaleDateString("en-US", {
|
||||||
|
|
||||||
month: "short",
|
month: "short",
|
||||||
|
|
||||||
day: timeRange === "7d" ? "numeric" : undefined,
|
day: timeRange === "7d" ? "numeric" : undefined,
|
||||||
|
|
||||||
}),
|
}),
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
yAxis: {
|
yAxis: {
|
||||||
|
|
||||||
format: (v: number) => `${v.toFixed(0)}%`,
|
format: (v: number) => `${v.toFixed(0)}%`,
|
||||||
|
|
||||||
},
|
},
|
||||||
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
{#snippet marks({ series, getAreaProps })}
|
{#snippet marks({ series, getAreaProps })}
|
||||||
|
|
||||||
<defs>
|
<defs>
|
||||||
|
|
||||||
<linearGradient id={chartUid} x1="0" y1="0" x2="0" y2="1">
|
<linearGradient id={chartUid} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stop-color={accentColor} stop-opacity="0.55" />
|
||||||
<stop offset="0%" stop-color={accentColor()} stop-opacity="0.55" />
|
<stop offset="100%" stop-color={accentColor} stop-opacity="0.04" />
|
||||||
|
|
||||||
<stop offset="100%" stop-color={accentColor()} stop-opacity="0.04" />
|
|
||||||
|
|
||||||
</linearGradient>
|
</linearGradient>
|
||||||
|
|
||||||
</defs>
|
</defs>
|
||||||
|
|
||||||
<ChartClipPath
|
<ChartClipPath
|
||||||
|
initialWidth={showPrediction ? undefined : 0}
|
||||||
initialWidth={0}
|
motion={showPrediction
|
||||||
|
? undefined
|
||||||
motion={{
|
: {
|
||||||
|
|
||||||
width: { type: "tween", duration: 900, easing: cubicInOut },
|
width: { type: "tween", duration: 900, easing: cubicInOut },
|
||||||
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
>
|
>
|
||||||
|
|
||||||
{#each series as s, i (s.key)}
|
{#each series as s, i (s.key)}
|
||||||
|
{@const meta = chartSeries.find((c) => c.key === s.key)}
|
||||||
{@const meta = chartSeries().find((c) => c.key === s.key)}
|
|
||||||
|
|
||||||
{@const isOverall = meta?.isOverall ?? s.key === "average"}
|
{@const isOverall = meta?.isOverall ?? s.key === "average"}
|
||||||
|
{@const isForecast = s.key === "forecast"}
|
||||||
|
|
||||||
|
{#if !isForecast}
|
||||||
<Area
|
<Area
|
||||||
|
|
||||||
{...getAreaProps(s, i)}
|
{...getAreaProps(s, i)}
|
||||||
|
|
||||||
fill={isOverall && !showSubjectTrends
|
fill={isOverall && !showSubjectTrends
|
||||||
|
|
||||||
? `url(#${chartUid})`
|
? `url(#${chartUid})`
|
||||||
|
|
||||||
: isOverall
|
: isOverall
|
||||||
|
? accentColor
|
||||||
? accentColor()
|
|
||||||
|
|
||||||
: "transparent"}
|
: "transparent"}
|
||||||
|
|
||||||
fill-opacity={isOverall ? (showSubjectTrends ? 0.08 : 0.35) : 0}
|
fill-opacity={isOverall ? (showSubjectTrends ? 0.08 : 0.35) : 0}
|
||||||
|
|
||||||
stroke={meta?.color ?? s.color}
|
stroke={meta?.color ?? s.color}
|
||||||
|
|
||||||
style={`stroke: ${meta?.color ?? s.color}`}
|
style={`stroke: ${meta?.color ?? s.color}`}
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|
||||||
</ChartClipPath>
|
</ChartClipPath>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
{#snippet aboveMarks()}
|
||||||
|
{#if showPrediction && forecastLineData.length > 1}
|
||||||
|
<Spline
|
||||||
|
data={forecastLineData}
|
||||||
|
x="date"
|
||||||
|
y="forecast"
|
||||||
|
curve={curveNatural}
|
||||||
|
class="bsplus-analytics-forecast-line"
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
{#snippet tooltip()}
|
{#snippet tooltip()}
|
||||||
|
|
||||||
<Chart.Tooltip
|
<Chart.Tooltip
|
||||||
|
|
||||||
labelFormatter={(v: Date) =>
|
labelFormatter={(v: Date) =>
|
||||||
|
|
||||||
v.toLocaleDateString("en-US", {
|
v.toLocaleDateString("en-US", {
|
||||||
|
|
||||||
month: "long",
|
month: "long",
|
||||||
|
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
|
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
|
|
||||||
})}
|
})}
|
||||||
|
|
||||||
indicator="line"
|
indicator="line"
|
||||||
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/snippet}
|
{/snippet}
|
||||||
|
|
||||||
</AreaChart>
|
</AreaChart>
|
||||||
|
|
||||||
</Chart.Container>
|
</Chart.Container>
|
||||||
|
{/key}
|
||||||
|
|
||||||
{:else}
|
{#if showPrediction && !canForecast}
|
||||||
|
<p class="bsplus-analytics-scale-hint">
|
||||||
<div class="bsplus-analytics-card-empty">
|
At least 3 graded periods are needed to generate a forecast.
|
||||||
|
</p>
|
||||||
<strong>No grade data for this range</strong>
|
{/if}
|
||||||
|
{:else}
|
||||||
<span>Complete assessments with released marks to see trends.</span>
|
<div class="bsplus-analytics-card-empty">
|
||||||
|
<strong>No grade data for this range</strong>
|
||||||
</div>
|
<span>Complete assessments with released marks to see trends.</span>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<footer class="bsplus-analytics-card-footer">
|
<footer class="bsplus-analytics-card-footer">
|
||||||
|
{#if showPrediction && forecast}
|
||||||
{#if trend().direction === "up"}
|
|
||||||
|
|
||||||
<span class="bsplus-analytics-trend-up"
|
|
||||||
|
|
||||||
>Trending up · {trend().percentage}% vs previous period</span
|
|
||||||
|
|
||||||
>
|
|
||||||
|
|
||||||
{:else if trend().direction === "down"}
|
|
||||||
|
|
||||||
<span class="bsplus-analytics-trend-down"
|
|
||||||
|
|
||||||
>Trending down · {trend().percentage}% vs previous period</span
|
|
||||||
|
|
||||||
>
|
|
||||||
|
|
||||||
{:else}
|
|
||||||
|
|
||||||
<span>Grades remain stable across this period</span>
|
|
||||||
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<br />
|
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
|
Projected average in {predictionMonths} month{predictionMonths === 1 ? "" : "s"}:
|
||||||
{filteredData().length} data points · {getTimeRangeLabel(timeRange)}
|
<strong>{forecast.projectedGrade}%</strong>
|
||||||
|
<span class="bsplus-analytics-footer-muted">
|
||||||
{#if showSubjectTrends && chartSeries().length > 1}
|
· {forecast.trendPerMonth >= 0 ? "+" : ""}{forecast.trendPerMonth}%/mo trend
|
||||||
|
· R² {forecast.rSquared.toFixed(2)}
|
||||||
· {chartSeries().length - 1} subject{chartSeries().length - 1 === 1 ? "" : "s"}
|
</span>
|
||||||
|
</span>
|
||||||
|
<br />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if trend.direction === "up"}
|
||||||
|
<span class="bsplus-analytics-trend-up"
|
||||||
|
>Trending up · {trend.percentage}% vs previous period</span
|
||||||
|
>
|
||||||
|
{:else if trend.direction === "down"}
|
||||||
|
<span class="bsplus-analytics-trend-down"
|
||||||
|
>Trending down · {trend.percentage}% vs previous period</span
|
||||||
|
>
|
||||||
|
{:else}
|
||||||
|
<span>Grades remain stable across this period</span>
|
||||||
|
{/if}
|
||||||
|
<br />
|
||||||
|
<span>
|
||||||
|
{historicalData.length} data points · {getTimeRangeLabel(timeRange)}
|
||||||
|
{#if showSubjectTrends && chartSeries.length > 1}
|
||||||
|
· {chartSeries.length - 1} subject{chartSeries.length - 1 === 1 ? "" : "s"}
|
||||||
|
{/if}
|
||||||
|
{#if showPrediction && forecast}
|
||||||
|
· {forecast.methodLabel}
|
||||||
|
{/if}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
let {
|
||||||
|
value = $bindable(3),
|
||||||
|
min = 1,
|
||||||
|
max = 12,
|
||||||
|
step = 1,
|
||||||
|
disabled = false,
|
||||||
|
} = $props<{
|
||||||
|
value?: number;
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
step?: number;
|
||||||
|
disabled?: boolean;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const percent = $derived(((value - min) / (max - min || 1)) * 100);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="bsplus-prediction-months-slider" class:is-disabled={disabled}>
|
||||||
|
<div class="bsplus-prediction-months-slider-track-wrap">
|
||||||
|
<div class="bsplus-prediction-months-slider-track" aria-hidden="true">
|
||||||
|
<div class="bsplus-prediction-months-slider-rail"></div>
|
||||||
|
<div class="bsplus-prediction-months-slider-fill" style:width="{percent}%"></div>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
class="bsplus-prediction-months-slider-input"
|
||||||
|
{min}
|
||||||
|
{max}
|
||||||
|
{step}
|
||||||
|
{disabled}
|
||||||
|
bind:value
|
||||||
|
aria-label="Forecast months ahead"
|
||||||
|
aria-valuemin={min}
|
||||||
|
aria-valuemax={max}
|
||||||
|
aria-valuenow={value}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="bsplus-analytics-range-value" aria-live="polite">
|
||||||
|
{value} month{value === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.bsplus-prediction-months-slider {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-prediction-months-slider.is-disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-prediction-months-slider-track-wrap {
|
||||||
|
position: relative;
|
||||||
|
flex: 1;
|
||||||
|
height: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-prediction-months-slider-track {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 0.35rem;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-prediction-months-slider-rail {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: color-mix(in srgb, var(--bsplus-analytics-muted) 28%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-prediction-months-slider-fill {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--bsplus-analytics-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-prediction-months-slider-input {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
height: 1.5rem;
|
||||||
|
background: transparent;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-prediction-months-slider-input::-webkit-slider-runnable-track {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
height: 0.35rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-prediction-months-slider-input::-moz-range-track {
|
||||||
|
height: 0.35rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-prediction-months-slider-input::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
margin-top: -0.325rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--bsplus-analytics-accent);
|
||||||
|
background: var(--bsplus-analytics-surface, #fff);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-prediction-months-slider-input::-moz-range-thumb {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 2px solid var(--bsplus-analytics-accent);
|
||||||
|
background: var(--bsplus-analytics-surface, #fff);
|
||||||
|
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.18);
|
||||||
|
cursor: grab;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -730,12 +730,38 @@
|
|||||||
|
|
||||||
.bsplus-analytics-card-controls {
|
.bsplus-analytics-card-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: wrap;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem 1rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bsplus-analytics-forecast-controls {
|
||||||
|
min-width: min(100%, 18rem);
|
||||||
|
max-width: 22rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-analytics-forecast-toggle {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-analytics-forecast-horizon {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 11rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-analytics-forecast-line {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bsplus-analytics-root [data-slot="chart"] .bsplus-analytics-forecast-line {
|
||||||
|
stroke: var(--bsplus-analytics-forecast, var(--bsplus-analytics-accent)) !important;
|
||||||
|
stroke-dasharray: 7 5 !important;
|
||||||
|
stroke-width: 2.5px;
|
||||||
|
fill: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.bsplus-analytics-card-control {
|
.bsplus-analytics-card-control {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -38,9 +38,10 @@ export function filterAssessmentsByTimeRange(
|
|||||||
|
|
||||||
export type TrendPoint = {
|
export type TrendPoint = {
|
||||||
date: Date;
|
date: Date;
|
||||||
average: number;
|
average: number | null;
|
||||||
count: number;
|
count: number;
|
||||||
[seriesKey: string]: number | Date;
|
forecast?: number;
|
||||||
|
[seriesKey: string]: number | Date | null | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TrendSeries = {
|
export type TrendSeries = {
|
||||||
|
|||||||
@@ -105,6 +105,10 @@ function syncThemeFromPage(target: HTMLElement) {
|
|||||||
|
|
||||||
target.style.setProperty("--bsplus-analytics-accent", palette.accent);
|
target.style.setProperty("--bsplus-analytics-accent", palette.accent);
|
||||||
target.style.setProperty("--bsplus-analytics-accent-subtle", palette.accentSubtle);
|
target.style.setProperty("--bsplus-analytics-accent-subtle", palette.accentSubtle);
|
||||||
|
target.style.setProperty(
|
||||||
|
"--bsplus-analytics-forecast",
|
||||||
|
`color-mix(in srgb, ${palette.accent} 72%, ${target.classList.contains("dark") ? "#f8fafc" : "#64748b"})`,
|
||||||
|
);
|
||||||
target.style.setProperty("--better-main", palette.accent);
|
target.style.setProperty("--better-main", palette.accent);
|
||||||
target.style.setProperty("--bsplus-theme-btn-primary-bg", palette.accent);
|
target.style.setProperty("--bsplus-theme-btn-primary-bg", palette.accent);
|
||||||
target.style.setProperty("--bsplus-theme-btn-primary-color", palette.onAccent);
|
target.style.setProperty("--bsplus-theme-btn-primary-color", palette.onAccent);
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
export type HistoricalGradePoint = {
|
||||||
|
date: Date;
|
||||||
|
average: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ForecastPoint = {
|
||||||
|
date: Date;
|
||||||
|
value: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GradeForecastResult = {
|
||||||
|
points: ForecastPoint[];
|
||||||
|
projectedGrade: number;
|
||||||
|
trendPerMonth: number;
|
||||||
|
rSquared: number;
|
||||||
|
methodLabel: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIN_POINTS = 3;
|
||||||
|
const MAX_MONTHS = 12;
|
||||||
|
|
||||||
|
function clampGrade(value: number): number {
|
||||||
|
return Math.min(100, Math.max(0, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonths(date: Date, months: number): Date {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setMonth(next.getMonth() + months);
|
||||||
|
next.setDate(1);
|
||||||
|
next.setHours(0, 0, 0, 0);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Holt's linear trend method (double exponential smoothing). */
|
||||||
|
function holtLinearForecast(
|
||||||
|
values: number[],
|
||||||
|
horizon: number,
|
||||||
|
alpha = 0.38,
|
||||||
|
beta = 0.14,
|
||||||
|
): number[] {
|
||||||
|
if (values.length < 2) return [];
|
||||||
|
|
||||||
|
let level = values[0];
|
||||||
|
let trend = values[1] - values[0];
|
||||||
|
|
||||||
|
for (let i = 1; i < values.length; i++) {
|
||||||
|
const prevLevel = level;
|
||||||
|
level = alpha * values[i] + (1 - alpha) * (level + trend);
|
||||||
|
trend = beta * (level - prevLevel) + (1 - beta) * trend;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from({ length: horizon }, (_, i) => level + (i + 1) * trend);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Weighted least squares with recency bias (exponential weights). */
|
||||||
|
function weightedLinearRegression(values: number[]): {
|
||||||
|
forecasts: number[];
|
||||||
|
slope: number;
|
||||||
|
rSquared: number;
|
||||||
|
} {
|
||||||
|
const n = values.length;
|
||||||
|
if (n < 2) {
|
||||||
|
return { forecasts: [], slope: 0, rSquared: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const decay = 0.72;
|
||||||
|
const weights = values.map((_, i) => decay ** (n - 1 - i));
|
||||||
|
const xs = values.map((_, i) => i);
|
||||||
|
|
||||||
|
let sumW = 0;
|
||||||
|
let sumWX = 0;
|
||||||
|
let sumWY = 0;
|
||||||
|
let sumWXX = 0;
|
||||||
|
let sumWXY = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const w = weights[i];
|
||||||
|
sumW += w;
|
||||||
|
sumWX += w * xs[i];
|
||||||
|
sumWY += w * values[i];
|
||||||
|
sumWXX += w * xs[i] * xs[i];
|
||||||
|
sumWXY += w * xs[i] * values[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
const denom = sumW * sumWXX - sumWX * sumWX;
|
||||||
|
const slope = denom === 0 ? 0 : (sumW * sumWXY - sumWX * sumWY) / denom;
|
||||||
|
const intercept = (sumWY - slope * sumWX) / sumW;
|
||||||
|
|
||||||
|
let ssRes = 0;
|
||||||
|
let ssTot = 0;
|
||||||
|
const meanY = sumWY / sumW;
|
||||||
|
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const predicted = intercept + slope * xs[i];
|
||||||
|
ssRes += weights[i] * (values[i] - predicted) ** 2;
|
||||||
|
ssTot += weights[i] * (values[i] - meanY) ** 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rSquared = ssTot === 0 ? 1 : Math.max(0, 1 - ssRes / ssTot);
|
||||||
|
const forecasts = Array.from({ length: MAX_MONTHS }, (_, i) =>
|
||||||
|
intercept + slope * (n - 1 + (i + 1)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { forecasts, slope, rSquared };
|
||||||
|
}
|
||||||
|
|
||||||
|
function monthKey(date: Date): string {
|
||||||
|
return date.toISOString().slice(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Collapse trend points to calendar months for stable forward projections. */
|
||||||
|
export function aggregateToMonthlyPoints(
|
||||||
|
historical: HistoricalGradePoint[],
|
||||||
|
): HistoricalGradePoint[] {
|
||||||
|
const buckets = new Map<string, number[]>();
|
||||||
|
|
||||||
|
for (const point of historical) {
|
||||||
|
const key = monthKey(point.date);
|
||||||
|
if (!buckets.has(key)) buckets.set(key, []);
|
||||||
|
buckets.get(key)!.push(point.average);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...buckets.entries()]
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([key, values]) => ({
|
||||||
|
date: new Date(`${key}-01`),
|
||||||
|
average: values.reduce((sum, value) => sum + value, 0) / values.length,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Blend Holt-Winters-style smoothing with weighted regression, then damp
|
||||||
|
* toward the recent mean so extreme projections stay realistic for grades.
|
||||||
|
*/
|
||||||
|
export function computeGradeForecast(
|
||||||
|
historical: HistoricalGradePoint[],
|
||||||
|
monthsForward: number,
|
||||||
|
): GradeForecastResult | null {
|
||||||
|
const horizon = Math.min(MAX_MONTHS, Math.max(1, Math.round(monthsForward)));
|
||||||
|
const sorted = [...historical]
|
||||||
|
.filter((p) => Number.isFinite(p.average))
|
||||||
|
.sort((a, b) => a.date.getTime() - b.date.getTime());
|
||||||
|
|
||||||
|
if (sorted.length < MIN_POINTS) return null;
|
||||||
|
|
||||||
|
const values = sorted.map((p) => p.average);
|
||||||
|
const holt = holtLinearForecast(values, horizon);
|
||||||
|
const regression = weightedLinearRegression(values);
|
||||||
|
const recentMean = values.slice(-3).reduce((sum, v) => sum + v, 0) / Math.min(3, values.length);
|
||||||
|
|
||||||
|
const lastDate = sorted[sorted.length - 1].date;
|
||||||
|
const points: ForecastPoint[] = [];
|
||||||
|
|
||||||
|
for (let i = 0; i < horizon; i++) {
|
||||||
|
const holtValue = holt[i] ?? regression.forecasts[i] ?? recentMean;
|
||||||
|
const regValue = regression.forecasts[i] ?? holtValue;
|
||||||
|
const blended = holtValue * 0.58 + regValue * 0.42;
|
||||||
|
const damped = blended * 0.86 + recentMean * 0.14;
|
||||||
|
|
||||||
|
points.push({
|
||||||
|
date: addMonths(lastDate, i + 1),
|
||||||
|
value: Math.round(clampGrade(damped) * 10) / 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const projectedGrade = points[points.length - 1]?.value ?? recentMean;
|
||||||
|
const trendPerMonth =
|
||||||
|
points.length > 1
|
||||||
|
? (points[points.length - 1].value - values[values.length - 1]) / points.length
|
||||||
|
: regression.slope;
|
||||||
|
|
||||||
|
return {
|
||||||
|
points,
|
||||||
|
projectedGrade,
|
||||||
|
trendPerMonth: Math.round(trendPerMonth * 10) / 10,
|
||||||
|
rSquared: Math.round(regression.rSquared * 100) / 100,
|
||||||
|
methodLabel: "Holt linear + weighted regression",
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user