@manta-eu/react 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/chart/ForecastChart.d.ts +41 -0
- package/dist/components/chart/ForecastChart.d.ts.map +1 -0
- package/dist/components/chart/ForecastChart.js +110 -0
- package/dist/components/chart/ForecastChart.js.map +1 -0
- package/dist/components/chart/ForecastEventCard.d.ts +28 -0
- package/dist/components/chart/ForecastEventCard.d.ts.map +1 -0
- package/dist/components/chart/ForecastEventCard.js +56 -0
- package/dist/components/chart/ForecastEventCard.js.map +1 -0
- package/dist/components/chart/ForecastHoverCard.d.ts +23 -0
- package/dist/components/chart/ForecastHoverCard.d.ts.map +1 -0
- package/dist/components/chart/ForecastHoverCard.js +22 -0
- package/dist/components/chart/ForecastHoverCard.js.map +1 -0
- package/dist/components/chart/ForecastLegend.d.ts +33 -0
- package/dist/components/chart/ForecastLegend.d.ts.map +1 -0
- package/dist/components/chart/ForecastLegend.js +38 -0
- package/dist/components/chart/ForecastLegend.js.map +1 -0
- package/dist/components/chart/forecast-model.d.ts +70 -0
- package/dist/components/chart/forecast-model.d.ts.map +1 -0
- package/dist/components/chart/forecast-model.js +89 -0
- package/dist/components/chart/forecast-model.js.map +1 -0
- package/dist/components/chart/forecast.d.ts +62 -0
- package/dist/components/chart/forecast.d.ts.map +1 -0
- package/dist/components/chart/forecast.js +90 -0
- package/dist/components/chart/forecast.js.map +1 -0
- package/dist/components/chart/index.d.ts +3 -0
- package/dist/components/chart/index.d.ts.map +1 -1
- package/dist/components/chart/index.js +1 -0
- package/dist/components/chart/index.js.map +1 -1
- package/dist/components/popover/index.d.ts +2 -1
- package/dist/components/popover/index.d.ts.map +1 -1
- package/dist/components/popover/index.js +2 -1
- package/dist/components/popover/index.js.map +1 -1
- package/dist/globals.css +58 -0
- package/dist/hooks/use-connection-problems.d.ts.map +1 -1
- package/dist/hooks/useAuth.d.ts +1 -0
- package/dist/hooks/useAuth.d.ts.map +1 -1
- package/dist/hooks/useAuth.js +1 -0
- package/dist/hooks/useAuth.js.map +1 -1
- package/dist/hooks/useValidateActionPlan.d.ts.map +1 -1
- package/dist/providers/AuthContext.d.ts +1 -0
- package/dist/providers/AuthContext.d.ts.map +1 -1
- package/dist/providers/AuthContext.js.map +1 -1
- package/package.json +8 -9
- package/src/components/chart/ForecastChart.tsx +383 -0
- package/src/components/chart/ForecastEventCard.tsx +163 -0
- package/src/components/chart/ForecastHoverCard.tsx +73 -0
- package/src/components/chart/ForecastLegend.tsx +97 -0
- package/src/components/chart/forecast-model.ts +130 -0
- package/src/components/chart/forecast.ts +144 -0
- package/src/components/chart/index.tsx +3 -0
- package/src/components/popover/index.tsx +2 -1
- package/src/globals.css +2 -0
- package/src/hooks/useAuth.ts +2 -0
- package/src/providers/AuthContext.ts +1 -0
- package/src/themes/manta-dark.css +2 -0
- package/src/themes/manta.css +2 -0
- package/src/themes/standard.css +2 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
import { isPresent } from "@manta-eu/lib/present";
|
|
2
|
+
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { Area, CartesianGrid, ComposedChart, Line, ReferenceDot, ReferenceLine, XAxis, YAxis } from "recharts";
|
|
4
|
+
import { renderAnnotations } from "./annotations";
|
|
5
|
+
import { ChartTooltip } from "./chart";
|
|
6
|
+
import { ChartFrame } from "./ChartFrame";
|
|
7
|
+
import {
|
|
8
|
+
forecastMargin,
|
|
9
|
+
forecastXAxisHeight,
|
|
10
|
+
formatCompact,
|
|
11
|
+
formatWhole,
|
|
12
|
+
nearestMetric,
|
|
13
|
+
partKey,
|
|
14
|
+
plotGeometry,
|
|
15
|
+
rangeKey,
|
|
16
|
+
valueAtY,
|
|
17
|
+
type ForecastEvent,
|
|
18
|
+
type ForecastPart,
|
|
19
|
+
type ForecastPlanShare,
|
|
20
|
+
} from "./forecast";
|
|
21
|
+
import {
|
|
22
|
+
betweenName,
|
|
23
|
+
buildForecastModel,
|
|
24
|
+
forecastParts as parts,
|
|
25
|
+
gainName,
|
|
26
|
+
metricColor,
|
|
27
|
+
withoutColor,
|
|
28
|
+
type ForecastBetween,
|
|
29
|
+
type ForecastMetric,
|
|
30
|
+
} from "./forecast-model";
|
|
31
|
+
import { ForecastEventCard, ForecastEventDot } from "./ForecastEventCard";
|
|
32
|
+
import { ForecastHoverCard } from "./ForecastHoverCard";
|
|
33
|
+
import { ForecastLegend } from "./ForecastLegend";
|
|
34
|
+
import { Popover, PopoverAnchor } from "#components/popover/index";
|
|
35
|
+
import { cn } from "#style";
|
|
36
|
+
|
|
37
|
+
export type ForecastChartProps = {
|
|
38
|
+
/** One row per x value; each row carries `x` and a number per metric key. */
|
|
39
|
+
data: Record<string, unknown>[];
|
|
40
|
+
/** The x value of Today: lines are solid up to it and dotted after. Omit it for a history-only chart. */
|
|
41
|
+
today?: string | number;
|
|
42
|
+
metrics: ForecastMetric[];
|
|
43
|
+
/** Legend name of the shading between a metric and its `withoutPlans` line. */
|
|
44
|
+
gainLabel?: string;
|
|
45
|
+
/** A faint fill between two metrics, such as profit between revenue and costs. */
|
|
46
|
+
between?: ForecastBetween[];
|
|
47
|
+
goal?: { value: number; label: string };
|
|
48
|
+
events?: ForecastEvent[];
|
|
49
|
+
/** Called when an event's dot or its card link is chosen; without it, an event's `href` is a plain link. */
|
|
50
|
+
onEventSelect?: (event: ForecastEvent) => void;
|
|
51
|
+
/** The plans behind each projected point of a metric, shown in its hover card (the first two). */
|
|
52
|
+
planShares?: ForecastPlanShare[];
|
|
53
|
+
/** Formats axis ticks. */
|
|
54
|
+
formatValue?: (value: number) => string;
|
|
55
|
+
/** Formats the figures in the hover card. */
|
|
56
|
+
formatDetail?: (value: number) => string;
|
|
57
|
+
/** Follows the signed gap in the hover card: "+$2,664 with the plans". */
|
|
58
|
+
changeLabel?: string;
|
|
59
|
+
projectedLabel?: string;
|
|
60
|
+
/** The tag on the Today line. */
|
|
61
|
+
todayLabel?: string;
|
|
62
|
+
/** Legend names of the solid and dotted parts of a single metric. */
|
|
63
|
+
legendLabels?: { historic?: string; projected?: string };
|
|
64
|
+
title?: string;
|
|
65
|
+
height?: number;
|
|
66
|
+
className?: string;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const projectedDash = "0.5 6";
|
|
70
|
+
const noBetween: ForecastBetween[] = [];
|
|
71
|
+
const noEvents: ForecastEvent[] = [];
|
|
72
|
+
const noPlanShares: ForecastPlanShare[] = [];
|
|
73
|
+
|
|
74
|
+
function TodayTag(props: { text: string; viewBox?: { x?: number; y?: number } }) {
|
|
75
|
+
const { text, viewBox } = props;
|
|
76
|
+
if (viewBox?.x === undefined || viewBox.y === undefined) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
return (
|
|
80
|
+
<foreignObject
|
|
81
|
+
x={viewBox.x - 100}
|
|
82
|
+
y={viewBox.y}
|
|
83
|
+
width={200}
|
|
84
|
+
height={20}
|
|
85
|
+
className="pointer-events-none overflow-visible"
|
|
86
|
+
>
|
|
87
|
+
<div className="flex justify-center">
|
|
88
|
+
<span className="h-5 rounded-full border border-default-border bg-card px-2 typography-tiny-medium leading-5 text-default-foreground">
|
|
89
|
+
{text}
|
|
90
|
+
</span>
|
|
91
|
+
</div>
|
|
92
|
+
</foreignObject>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function ForecastChart(props: ForecastChartProps) {
|
|
97
|
+
const {
|
|
98
|
+
data,
|
|
99
|
+
today,
|
|
100
|
+
metrics,
|
|
101
|
+
gainLabel = "Added by plans",
|
|
102
|
+
between = noBetween,
|
|
103
|
+
goal,
|
|
104
|
+
events = noEvents,
|
|
105
|
+
onEventSelect,
|
|
106
|
+
planShares = noPlanShares,
|
|
107
|
+
formatValue = formatCompact,
|
|
108
|
+
formatDetail = formatWhole,
|
|
109
|
+
changeLabel = "with the plans",
|
|
110
|
+
projectedLabel = "projected",
|
|
111
|
+
todayLabel = "Today",
|
|
112
|
+
legendLabels,
|
|
113
|
+
title,
|
|
114
|
+
height = 320,
|
|
115
|
+
className,
|
|
116
|
+
} = props;
|
|
117
|
+
const id = useId().replace(/:/g, "");
|
|
118
|
+
const fadeId = `forecast-fade-${id}`;
|
|
119
|
+
const cardId = `forecast-event-${id}`;
|
|
120
|
+
const plotRef = useRef<HTMLDivElement>(null);
|
|
121
|
+
const closeTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
|
122
|
+
const [activeEvent, setActiveEvent] = useState<{ index: number; cx: number; cy: number }>();
|
|
123
|
+
const [focusKey, setFocusKey] = useState<string>();
|
|
124
|
+
|
|
125
|
+
const model = useMemo(
|
|
126
|
+
() =>
|
|
127
|
+
buildForecastModel({
|
|
128
|
+
data,
|
|
129
|
+
...(today === undefined ? {} : { today }),
|
|
130
|
+
metrics,
|
|
131
|
+
between,
|
|
132
|
+
gainLabel,
|
|
133
|
+
...(goal ? { goal } : {}),
|
|
134
|
+
...(legendLabels ? { legendLabels } : {}),
|
|
135
|
+
}),
|
|
136
|
+
[data, today, metrics, between, goal, gainLabel, legendLabels],
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const emptyKeys = model.emptyKeys.join(", ");
|
|
140
|
+
useEffect(() => {
|
|
141
|
+
if (isPresent(emptyKeys)) {
|
|
142
|
+
console.warn(`ForecastChart: no numbers in data for ${emptyKeys}; check the metric keys`);
|
|
143
|
+
}
|
|
144
|
+
}, [emptyKeys]);
|
|
145
|
+
|
|
146
|
+
const missingToday = model.todayMissing ? String(today) : "";
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (isPresent(missingToday)) {
|
|
149
|
+
console.warn(
|
|
150
|
+
`ForecastChart: today "${missingToday}" matches no row's x; the Today line and projection are hidden`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}, [missingToday]);
|
|
154
|
+
|
|
155
|
+
const { overlay, withPlans, rows, domain, ticks, firstColor } = model;
|
|
156
|
+
const gainOpacity: Record<ForecastPart, number> = overlay ? { past: 0.5, ahead: 0.34 } : { past: 0.24, ahead: 0.14 };
|
|
157
|
+
const betweenOpacity: Record<ForecastPart, number> = { past: 0.08, ahead: 0.05 };
|
|
158
|
+
const geometry = plotGeometry(height);
|
|
159
|
+
const focusedMetric = model.hoverMetrics.find((metric) => metric.key === focusKey) ?? model.hoverMetrics[0];
|
|
160
|
+
|
|
161
|
+
function followPointer(state: { chartY?: number; activePayload?: { payload?: Record<string, unknown> }[] }) {
|
|
162
|
+
const row = state.activePayload?.[0]?.payload;
|
|
163
|
+
if (state.chartY === undefined || row === undefined) {
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
const nearest = nearestMetric(
|
|
167
|
+
row,
|
|
168
|
+
metrics.map((metric) => metric.key),
|
|
169
|
+
valueAtY(state.chartY, geometry.top, geometry.height, domain),
|
|
170
|
+
);
|
|
171
|
+
if (nearest !== focusKey) {
|
|
172
|
+
setFocusKey(nearest);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function cancelClose() {
|
|
177
|
+
clearTimeout(closeTimer.current);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function closeSoon() {
|
|
181
|
+
cancelClose();
|
|
182
|
+
closeTimer.current = setTimeout(() => setActiveEvent(undefined), 150);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function closeUnlessInside(next: EventTarget | null) {
|
|
186
|
+
const inside = [plotRef.current, document.getElementById(cardId)].some(
|
|
187
|
+
(container) => next instanceof Node && container?.contains(next),
|
|
188
|
+
);
|
|
189
|
+
if (!inside) {
|
|
190
|
+
setActiveEvent(undefined);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function showEvent(index: number, cx: number, cy: number) {
|
|
195
|
+
cancelClose();
|
|
196
|
+
setActiveEvent({ index, cx, cy });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function rangeArea(name: string, part: ForecastPart, color: string, opacity: number) {
|
|
200
|
+
return (
|
|
201
|
+
<Area
|
|
202
|
+
key={rangeKey(name, part)}
|
|
203
|
+
dataKey={rangeKey(name, part)}
|
|
204
|
+
type="monotone"
|
|
205
|
+
fill={color}
|
|
206
|
+
fillOpacity={opacity}
|
|
207
|
+
stroke="none"
|
|
208
|
+
tooltipType="none"
|
|
209
|
+
activeDot={false}
|
|
210
|
+
isAnimationActive={false}
|
|
211
|
+
/>
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function line(key: string, part: ForecastPart, color: string, width: number, withPlans: boolean) {
|
|
216
|
+
const activeDot = { r: 3.5, fill: color, stroke: "var(--color-card)", strokeWidth: 1.5 };
|
|
217
|
+
return (
|
|
218
|
+
<Line
|
|
219
|
+
key={partKey(key, part)}
|
|
220
|
+
dataKey={partKey(key, part)}
|
|
221
|
+
type="monotone"
|
|
222
|
+
stroke={color}
|
|
223
|
+
strokeWidth={width}
|
|
224
|
+
strokeLinejoin="round"
|
|
225
|
+
strokeLinecap="round"
|
|
226
|
+
strokeDasharray={part === "ahead" && withPlans ? projectedDash : undefined}
|
|
227
|
+
dot={false}
|
|
228
|
+
activeDot={activeEvent ? false : activeDot}
|
|
229
|
+
tooltipType="none"
|
|
230
|
+
isAnimationActive={false}
|
|
231
|
+
/>
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const shownEvent = activeEvent ? events[activeEvent.index] : undefined;
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<div className={cn("w-full", className)}>
|
|
239
|
+
{isPresent(title) ? (
|
|
240
|
+
<p className="typography-small-medium mb-2 text-center text-default-foreground">{title}</p>
|
|
241
|
+
) : null}
|
|
242
|
+
<ForecastLegend items={model.legendItems} />
|
|
243
|
+
<div ref={plotRef} className="relative">
|
|
244
|
+
<ChartFrame config={model.config} height={height}>
|
|
245
|
+
<ComposedChart accessibilityLayer data={rows} margin={forecastMargin} onMouseMove={followPointer}>
|
|
246
|
+
<defs>
|
|
247
|
+
<linearGradient id={fadeId} x1="0" y1="0" x2="0" y2="1">
|
|
248
|
+
<stop offset="0%" stopColor={model.fadeColor} stopOpacity={overlay ? 0.3 : 0.34} />
|
|
249
|
+
<stop offset="100%" stopColor={model.fadeColor} stopOpacity={0} />
|
|
250
|
+
</linearGradient>
|
|
251
|
+
</defs>
|
|
252
|
+
<CartesianGrid vertical={false} strokeDasharray="2 3" />
|
|
253
|
+
<XAxis
|
|
254
|
+
dataKey="x"
|
|
255
|
+
height={forecastXAxisHeight}
|
|
256
|
+
tickLine={false}
|
|
257
|
+
axisLine={false}
|
|
258
|
+
tickMargin={12}
|
|
259
|
+
minTickGap={24}
|
|
260
|
+
/>
|
|
261
|
+
<YAxis
|
|
262
|
+
tickLine={false}
|
|
263
|
+
axisLine={false}
|
|
264
|
+
width={56}
|
|
265
|
+
domain={domain}
|
|
266
|
+
ticks={ticks}
|
|
267
|
+
interval={0}
|
|
268
|
+
tickFormatter={formatValue}
|
|
269
|
+
allowDataOverflow
|
|
270
|
+
/>
|
|
271
|
+
{model.fadeKeys.map(({ part, key }) => (
|
|
272
|
+
<Area
|
|
273
|
+
key={`fade-${part}`}
|
|
274
|
+
dataKey={partKey(key, part)}
|
|
275
|
+
baseValue="dataMin"
|
|
276
|
+
type="monotone"
|
|
277
|
+
stroke="none"
|
|
278
|
+
fill={`url(#${fadeId})`}
|
|
279
|
+
tooltipType="none"
|
|
280
|
+
activeDot={false}
|
|
281
|
+
isAnimationActive={false}
|
|
282
|
+
/>
|
|
283
|
+
))}
|
|
284
|
+
{between.flatMap((_, index) =>
|
|
285
|
+
parts.map((part) => rangeArea(betweenName(index), part, firstColor, betweenOpacity[part])),
|
|
286
|
+
)}
|
|
287
|
+
{withPlans.flatMap((entry) =>
|
|
288
|
+
parts.map((part) =>
|
|
289
|
+
rangeArea(gainName(entry.index), part, metricColor(entry.metric, entry.index), gainOpacity[part]),
|
|
290
|
+
),
|
|
291
|
+
)}
|
|
292
|
+
{isPresent(goal)
|
|
293
|
+
? renderAnnotations([
|
|
294
|
+
{ kind: "reference-line", axis: "y", value: goal.value, label: goal.label, tone: "neutral" },
|
|
295
|
+
])
|
|
296
|
+
: null}
|
|
297
|
+
{model.todayIndex >= 0 && today !== undefined ? (
|
|
298
|
+
<ReferenceLine
|
|
299
|
+
x={today}
|
|
300
|
+
stroke="var(--color-default-foreground)"
|
|
301
|
+
strokeOpacity={0.4}
|
|
302
|
+
label={<TodayTag text={todayLabel} />}
|
|
303
|
+
/>
|
|
304
|
+
) : null}
|
|
305
|
+
<ChartTooltip
|
|
306
|
+
active={shownEvent ? false : undefined}
|
|
307
|
+
cursor={{ stroke: "var(--color-default-border)" }}
|
|
308
|
+
content={
|
|
309
|
+
<ForecastHoverCard
|
|
310
|
+
metric={focusedMetric}
|
|
311
|
+
formatDetail={formatDetail}
|
|
312
|
+
changeLabel={changeLabel}
|
|
313
|
+
projectedLabel={projectedLabel}
|
|
314
|
+
planShares={planShares}
|
|
315
|
+
/>
|
|
316
|
+
}
|
|
317
|
+
/>
|
|
318
|
+
{withPlans.flatMap((entry) =>
|
|
319
|
+
parts.map((part) => line(entry.without.key, part, withoutColor, overlay ? 1.5 : 2, false)),
|
|
320
|
+
)}
|
|
321
|
+
{metrics.flatMap((metric, index) =>
|
|
322
|
+
(["ahead", "past"] as const).map((part) =>
|
|
323
|
+
line(metric.key, part, metricColor(metric, index), index === 0 ? 2.5 : 2.25, true),
|
|
324
|
+
),
|
|
325
|
+
)}
|
|
326
|
+
{events.map((event, index) => {
|
|
327
|
+
const metricIndex = metrics.findIndex((metric) => metric.key === event.metric);
|
|
328
|
+
const metric = metrics[metricIndex];
|
|
329
|
+
const value = data.find((row) => row.x === event.x)?.[event.metric];
|
|
330
|
+
if (typeof value !== "number" || metric === undefined) {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
return (
|
|
334
|
+
<ReferenceDot
|
|
335
|
+
key={`${event.metric}-${event.x}-${event.title}`}
|
|
336
|
+
x={event.x}
|
|
337
|
+
y={value}
|
|
338
|
+
shape={(dot: { cx?: number; cy?: number }) => (
|
|
339
|
+
<ForecastEventDot
|
|
340
|
+
event={event}
|
|
341
|
+
color={metricColor(metric, metricIndex)}
|
|
342
|
+
{...(dot.cx === undefined ? {} : { cx: dot.cx })}
|
|
343
|
+
{...(dot.cy === undefined ? {} : { cy: dot.cy })}
|
|
344
|
+
active={activeEvent?.index === index}
|
|
345
|
+
cardId={cardId}
|
|
346
|
+
{...(onEventSelect ? { onSelect: onEventSelect } : {})}
|
|
347
|
+
onShow={(cx, cy) => showEvent(index, cx, cy)}
|
|
348
|
+
onLeave={closeSoon}
|
|
349
|
+
onBlur={closeUnlessInside}
|
|
350
|
+
/>
|
|
351
|
+
)}
|
|
352
|
+
/>
|
|
353
|
+
);
|
|
354
|
+
})}
|
|
355
|
+
</ComposedChart>
|
|
356
|
+
</ChartFrame>
|
|
357
|
+
<Popover
|
|
358
|
+
key={activeEvent?.index}
|
|
359
|
+
open={shownEvent !== undefined}
|
|
360
|
+
onOpenChange={(open) => !open && setActiveEvent(undefined)}
|
|
361
|
+
>
|
|
362
|
+
<PopoverAnchor asChild>
|
|
363
|
+
<span
|
|
364
|
+
aria-hidden="true"
|
|
365
|
+
className="pointer-events-none absolute size-0"
|
|
366
|
+
style={{ left: activeEvent?.cx ?? 0, top: activeEvent?.cy ?? 0 }}
|
|
367
|
+
/>
|
|
368
|
+
</PopoverAnchor>
|
|
369
|
+
{shownEvent ? (
|
|
370
|
+
<ForecastEventCard
|
|
371
|
+
event={shownEvent}
|
|
372
|
+
id={cardId}
|
|
373
|
+
{...(onEventSelect ? { onSelect: onEventSelect } : {})}
|
|
374
|
+
onEnter={cancelClose}
|
|
375
|
+
onLeave={closeSoon}
|
|
376
|
+
onBlur={closeUnlessInside}
|
|
377
|
+
/>
|
|
378
|
+
) : null}
|
|
379
|
+
</Popover>
|
|
380
|
+
</div>
|
|
381
|
+
</div>
|
|
382
|
+
);
|
|
383
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { isPresent } from "@manta-eu/lib/present";
|
|
2
|
+
import { ArrowUpRight } from "lucide-react";
|
|
3
|
+
import type { FocusEvent, KeyboardEvent, ReactNode } from "react";
|
|
4
|
+
import type { ForecastEvent } from "./forecast";
|
|
5
|
+
import { PopoverArrow, PopoverContent } from "#components/popover/index";
|
|
6
|
+
import { cn } from "#style";
|
|
7
|
+
|
|
8
|
+
const eventLabels: Record<ForecastEvent["status"], string> = {
|
|
9
|
+
applied: "Applied",
|
|
10
|
+
waiting: "Waiting for your approval",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function eventLabel(event: ForecastEvent): string {
|
|
14
|
+
return event.label ?? eventLabels[event.status];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type ForecastEventDotProps = {
|
|
18
|
+
event: ForecastEvent;
|
|
19
|
+
color: string;
|
|
20
|
+
cx?: number;
|
|
21
|
+
cy?: number;
|
|
22
|
+
active: boolean;
|
|
23
|
+
cardId: string;
|
|
24
|
+
onSelect?: (event: ForecastEvent) => void;
|
|
25
|
+
onShow: (cx: number, cy: number) => void;
|
|
26
|
+
onLeave: () => void;
|
|
27
|
+
onBlur: (next: EventTarget | null) => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** A dot on a metric's line. With `onSelect` it acts on click or Enter; otherwise an `href` makes it a plain link. */
|
|
31
|
+
export function ForecastEventDot(props: ForecastEventDotProps) {
|
|
32
|
+
const { event, color, cx, cy, active, cardId, onSelect, onShow, onLeave, onBlur } = props;
|
|
33
|
+
if (cx === undefined || cy === undefined) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const label = `${eventLabel(event)}: ${event.title}`;
|
|
37
|
+
const waiting = event.status === "waiting";
|
|
38
|
+
const circles = (
|
|
39
|
+
<>
|
|
40
|
+
<circle cx={cx} cy={cy} r={14} fill="transparent" />
|
|
41
|
+
<circle
|
|
42
|
+
cx={cx}
|
|
43
|
+
cy={cy}
|
|
44
|
+
r={10}
|
|
45
|
+
fill={color}
|
|
46
|
+
className={cn(
|
|
47
|
+
"opacity-0 transition-opacity group-hover/event:opacity-20 group-focus-visible/event:opacity-35",
|
|
48
|
+
active && "opacity-20",
|
|
49
|
+
)}
|
|
50
|
+
/>
|
|
51
|
+
<circle
|
|
52
|
+
cx={cx}
|
|
53
|
+
cy={cy}
|
|
54
|
+
r={active ? 6.5 : 5}
|
|
55
|
+
fill={waiting ? "var(--color-card)" : color}
|
|
56
|
+
stroke={waiting ? color : "var(--color-card)"}
|
|
57
|
+
strokeWidth={2}
|
|
58
|
+
/>
|
|
59
|
+
</>
|
|
60
|
+
);
|
|
61
|
+
const shared = {
|
|
62
|
+
"aria-label": label,
|
|
63
|
+
"aria-describedby": active ? cardId : undefined,
|
|
64
|
+
className: "group/event cursor-pointer outline-none",
|
|
65
|
+
onMouseEnter: () => onShow(cx, cy),
|
|
66
|
+
onMouseLeave: onLeave,
|
|
67
|
+
onFocus: () => onShow(cx, cy),
|
|
68
|
+
onBlur: (focusEvent: FocusEvent) => onBlur(focusEvent.relatedTarget),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
if (onSelect === undefined && isPresent(event.href)) {
|
|
72
|
+
return (
|
|
73
|
+
<a href={event.href} {...shared}>
|
|
74
|
+
{circles}
|
|
75
|
+
</a>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (onSelect === undefined) {
|
|
80
|
+
return (
|
|
81
|
+
<g role="img" tabIndex={0} {...shared} className="group/event outline-none">
|
|
82
|
+
{circles}
|
|
83
|
+
</g>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function handleKeyDown(keyEvent: KeyboardEvent<SVGGElement>) {
|
|
88
|
+
if (keyEvent.key === "Enter" || keyEvent.key === " ") {
|
|
89
|
+
keyEvent.preventDefault();
|
|
90
|
+
onSelect?.(event);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<g role="button" tabIndex={0} {...shared} onClick={() => onSelect(event)} onKeyDown={handleKeyDown}>
|
|
96
|
+
{circles}
|
|
97
|
+
</g>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
type ForecastEventCardProps = {
|
|
102
|
+
event: ForecastEvent;
|
|
103
|
+
id: string;
|
|
104
|
+
onSelect?: (event: ForecastEvent) => void;
|
|
105
|
+
onEnter: () => void;
|
|
106
|
+
onLeave: () => void;
|
|
107
|
+
onBlur: (next: EventTarget | null) => void;
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/** The dark card over a dot: its label, title, value and, with an `href`, an "Open plan" link. */
|
|
111
|
+
export function ForecastEventCard(props: ForecastEventCardProps) {
|
|
112
|
+
const { event, id, onSelect, onEnter, onLeave, onBlur } = props;
|
|
113
|
+
const link: ReactNode = isPresent(event.href) ? (
|
|
114
|
+
<a
|
|
115
|
+
href={event.href}
|
|
116
|
+
onClick={(clickEvent) => {
|
|
117
|
+
if (onSelect) {
|
|
118
|
+
clickEvent.preventDefault();
|
|
119
|
+
onSelect(event);
|
|
120
|
+
}
|
|
121
|
+
}}
|
|
122
|
+
className="mt-1 inline-flex items-center gap-1 self-start typography-small underline underline-offset-2"
|
|
123
|
+
>
|
|
124
|
+
{event.linkLabel ?? "Open plan"}
|
|
125
|
+
<ArrowUpRight className="size-3.5" aria-hidden="true" />
|
|
126
|
+
</a>
|
|
127
|
+
) : null;
|
|
128
|
+
return (
|
|
129
|
+
<PopoverContent
|
|
130
|
+
id={id}
|
|
131
|
+
role={isPresent(event.href) ? undefined : "tooltip"}
|
|
132
|
+
side="top"
|
|
133
|
+
sideOffset={12}
|
|
134
|
+
collisionPadding={8}
|
|
135
|
+
onOpenAutoFocus={(focusEvent) => focusEvent.preventDefault()}
|
|
136
|
+
onCloseAutoFocus={(focusEvent) => focusEvent.preventDefault()}
|
|
137
|
+
className={cn(
|
|
138
|
+
"flex w-max min-w-50 max-w-65 flex-col gap-1 rounded-md-v4 border-0 px-3.5 pt-2.5 pb-3",
|
|
139
|
+
"bg-inverse text-inverse-foreground shadow-xl ring-1 ring-inverse-foreground/10",
|
|
140
|
+
!isPresent(event.href) && "pointer-events-none",
|
|
141
|
+
)}
|
|
142
|
+
onMouseEnter={onEnter}
|
|
143
|
+
onMouseLeave={onLeave}
|
|
144
|
+
onBlur={(focusEvent) => onBlur(focusEvent.relatedTarget)}
|
|
145
|
+
>
|
|
146
|
+
<span className="typography-tiny opacity-70">{eventLabel(event)}</span>
|
|
147
|
+
<strong className="typography-body-medium">{event.title}</strong>
|
|
148
|
+
{isPresent(event.value) ? (
|
|
149
|
+
<span
|
|
150
|
+
className="typography-small-semibold tabular-nums"
|
|
151
|
+
style={{ color: "color-mix(in srgb, var(--color-accent-foreground) 55%, var(--color-inverse-foreground))" }}
|
|
152
|
+
>
|
|
153
|
+
{event.value}
|
|
154
|
+
{isPresent(event.valueNote) ? (
|
|
155
|
+
<small className="ml-1 typography-tiny opacity-70">{event.valueNote}</small>
|
|
156
|
+
) : null}
|
|
157
|
+
</span>
|
|
158
|
+
) : null}
|
|
159
|
+
{link}
|
|
160
|
+
<PopoverArrow className="fill-inverse" width={12} height={6} />
|
|
161
|
+
</PopoverContent>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { isPresent } from "@manta-eu/lib/present";
|
|
2
|
+
import { plansAt, projectedKey, signedChange, type ForecastPlanShare } from "./forecast";
|
|
3
|
+
import { cn } from "#style";
|
|
4
|
+
|
|
5
|
+
export type ForecastHoverMetric = { key: string; label: string; withoutKey?: string; lowerIsBetter: boolean };
|
|
6
|
+
|
|
7
|
+
type ForecastHoverCardProps = {
|
|
8
|
+
metric: ForecastHoverMetric | undefined;
|
|
9
|
+
formatDetail: (value: number) => string;
|
|
10
|
+
changeLabel: string;
|
|
11
|
+
projectedLabel: string;
|
|
12
|
+
planShares: readonly ForecastPlanShare[];
|
|
13
|
+
active?: boolean;
|
|
14
|
+
label?: string | number;
|
|
15
|
+
payload?: { payload?: Record<string, unknown> }[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** The card for the line under the pointer: its figure and, when projected, the gap and the plans behind it. */
|
|
19
|
+
export function ForecastHoverCard(props: ForecastHoverCardProps) {
|
|
20
|
+
const { metric, formatDetail, changeLabel, projectedLabel, planShares, active, label, payload } = props;
|
|
21
|
+
const row = payload?.[0]?.payload;
|
|
22
|
+
const value = metric === undefined ? undefined : row?.[metric.key];
|
|
23
|
+
if (active !== true || row === undefined || metric === undefined || typeof value !== "number") {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const projected = row[projectedKey] === true;
|
|
27
|
+
const without = isPresent(metric.withoutKey) ? row[metric.withoutKey] : undefined;
|
|
28
|
+
const x = row.x;
|
|
29
|
+
function helps(change: number) {
|
|
30
|
+
return metric?.lowerIsBetter === true ? change <= 0 : change >= 0;
|
|
31
|
+
}
|
|
32
|
+
const plans =
|
|
33
|
+
projected && (typeof x === "string" || typeof x === "number") ? plansAt(planShares, x, metric.key).slice(0, 2) : [];
|
|
34
|
+
return (
|
|
35
|
+
<div className="min-w-50 max-w-75 rounded-md-v4 border border-default-border bg-card px-3 py-2.5 text-default-foreground shadow-xl">
|
|
36
|
+
<p className="mb-1.5 typography-tiny text-muted-foreground">
|
|
37
|
+
{label}
|
|
38
|
+
{projected ? ` · ${projectedLabel}` : ""}
|
|
39
|
+
</p>
|
|
40
|
+
<p className="flex items-baseline justify-between gap-4 typography-small text-muted-foreground">
|
|
41
|
+
<span>{metric.label}</span>
|
|
42
|
+
<strong className="typography-body-semibold tabular-nums text-default-foreground">{formatDetail(value)}</strong>
|
|
43
|
+
</p>
|
|
44
|
+
{projected && typeof without === "number" ? (
|
|
45
|
+
<p
|
|
46
|
+
className={cn(
|
|
47
|
+
"typography-small-medium tabular-nums",
|
|
48
|
+
helps(value - without) ? "text-accent-foreground" : "text-muted-foreground",
|
|
49
|
+
)}
|
|
50
|
+
>
|
|
51
|
+
{signedChange(value, without, formatDetail)} {changeLabel}
|
|
52
|
+
</p>
|
|
53
|
+
) : null}
|
|
54
|
+
{isPresent(plans) ? (
|
|
55
|
+
<div className="mt-2 grid gap-0.5 border-t border-default-border pt-2">
|
|
56
|
+
{plans.map((plan) => (
|
|
57
|
+
<p key={plan.label} className="flex items-baseline justify-between gap-3 typography-tiny">
|
|
58
|
+
<span className="min-w-0">{plan.label}</span>
|
|
59
|
+
<strong
|
|
60
|
+
className={cn(
|
|
61
|
+
"flex-none typography-tiny-semibold tabular-nums",
|
|
62
|
+
helps(plan.value) ? "text-accent-foreground" : "text-muted-foreground",
|
|
63
|
+
)}
|
|
64
|
+
>
|
|
65
|
+
{signedChange(plan.value, 0, formatDetail)}
|
|
66
|
+
</strong>
|
|
67
|
+
</p>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
) : null}
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|