feat(demo): simplify graphic overlay markers

This commit is contained in:
Justineo
2026-02-11 18:23:45 +08:00
committed by GU Yiling
parent 802f17a807
commit 8b3192f993
5 changed files with 241 additions and 252 deletions

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, shallowRef, watchEffect } from "vue";
import { computed, onUnmounted, shallowRef, watch, watchEffect } from "vue";
import type { ComponentExposed } from "vue-component-type-helpers";
import { use } from "echarts/core";
import { LineChart } from "echarts/charts";
@@ -8,7 +8,7 @@ import { GridComponent, GraphicComponent, TooltipComponent } from "echarts/compo
import VChart from "../../src/ECharts";
import VExample from "./Example.vue";
import { useDemoDark } from "../composables/useDemoDark";
import { GCircle, GGroup, GLine, GRect, GText } from "../../src/graphic";
import { GBezierCurve, GCircle, GGroup, GRect, GText } from "../../src/graphic";
import { resolveGraphicOverlayTokens } from "./graphic-overlay/GraphicOverlayTokens";
import { buildGraphicOverlayLayout } from "./graphic-overlay/useGraphicOverlayLayout";
import {
@@ -17,14 +17,15 @@ import {
useGraphicOverlayData,
} from "./graphic-overlay/useGraphicOverlayData";
import type { OverlayViewport } from "./graphic-overlay/types";
import type { EChartsOption } from "echarts";
use([LineChart, GridComponent, TooltipComponent, GraphicComponent]);
const GRID = {
left: 9,
right: 6,
top: 30,
bottom: 14,
left: 8,
right: 5,
top: 18,
bottom: 12,
};
const chartRef = shallowRef<ComponentExposed<typeof VChart>>();
@@ -57,56 +58,116 @@ watchEffect((onCleanup) => {
});
});
const {
values,
markers,
focusedMarkerId,
summary,
randomizeTrend,
rotateFocus,
focusMarker,
toggleMarker,
} = useGraphicOverlayData();
const { values, markers, focusedMarkerId, randomizeTrend, rotateFocus, focusMarker, toggleMarker } =
useGraphicOverlayData();
const CHART_UPDATE_ANIMATION_MS = 300;
const CHART_UPDATE_ANIMATION_EASING = "cubicOut";
const overlayValues = shallowRef<number[]>([...values.value]);
let overlayRaf = 0;
const option = computed(() => ({
grid: {
left: `${GRID.left}%`,
right: `${GRID.right}%`,
top: `${GRID.top}%`,
bottom: `${GRID.bottom}%`,
function easeOutCubic(t: number): number {
return 1 - (1 - t) * (1 - t) * (1 - t);
}
watch(
() => values.value,
(nextValues) => {
const to = [...nextValues];
const from = [...overlayValues.value];
if (
typeof requestAnimationFrame === "undefined" ||
from.length !== to.length ||
CHART_UPDATE_ANIMATION_MS <= 0
) {
overlayValues.value = to;
return;
}
if (overlayRaf) {
cancelAnimationFrame(overlayRaf);
overlayRaf = 0;
}
let startedAt = 0;
const tick = (now: number) => {
if (!startedAt) {
startedAt = now;
}
const elapsed = now - startedAt;
const progress = Math.min(elapsed / CHART_UPDATE_ANIMATION_MS, 1);
const eased = easeOutCubic(progress);
overlayValues.value = to.map((target, index) =>
Math.round(from[index] + (target - from[index]) * eased),
);
if (progress < 1) {
overlayRaf = requestAnimationFrame(tick);
return;
}
overlayRaf = 0;
overlayValues.value = to;
};
overlayRaf = requestAnimationFrame(tick);
},
tooltip: { trigger: "axis" },
xAxis: {
type: "category",
boundaryGap: false,
data: OVERLAY_DAYS,
},
yAxis: {
type: "value",
min: 0,
max: OVERLAY_Y_MAX,
splitLine: {
lineStyle: {
opacity: 0.22,
{ immediate: true },
);
onUnmounted(() => {
if (overlayRaf) {
cancelAnimationFrame(overlayRaf);
overlayRaf = 0;
}
});
const option = computed(
() =>
({
animationDurationUpdate: CHART_UPDATE_ANIMATION_MS,
animationEasingUpdate: CHART_UPDATE_ANIMATION_EASING,
grid: {
left: `${GRID.left}%`,
right: `${GRID.right}%`,
top: `${GRID.top}%`,
bottom: `${GRID.bottom}%`,
},
},
},
series: [
{
type: "line",
smooth: true,
symbol: "circle",
symbolSize: 6,
lineStyle: { width: 3 },
data: values.value,
},
],
}));
tooltip: { trigger: "axis" },
xAxis: {
type: "category",
boundaryGap: false,
data: OVERLAY_DAYS,
},
yAxis: {
type: "value",
min: 0,
max: OVERLAY_Y_MAX,
splitLine: {
lineStyle: {
opacity: 0.22,
},
},
},
series: [
{
type: "line",
animationDurationUpdate: CHART_UPDATE_ANIMATION_MS,
animationEasingUpdate: CHART_UPDATE_ANIMATION_EASING,
smooth: true,
symbol: "circle",
symbolSize: 6,
lineStyle: { width: 3 },
data: values.value,
},
],
}) as const satisfies EChartsOption,
);
const layout = computed(() =>
buildGraphicOverlayLayout({
days: OVERLAY_DAYS,
values: values.value,
values: overlayValues.value,
markers: markers.value,
focusedMarkerId: focusedMarkerId.value,
yMax: OVERLAY_Y_MAX,
@@ -114,84 +175,29 @@ const layout = computed(() =>
}),
);
const summaryRows = computed(() => [
{ key: "peak", label: "Peak", value: summary.value.peak },
{ key: "low", label: "Low", value: summary.value.low },
{ key: "delta", label: "Week delta", value: summary.value.delta },
{ key: "focus", label: "Focus", value: summary.value.focus },
]);
const isDark = useDemoDark();
const ui = computed(() => resolveGraphicOverlayTokens(isDark.value));
function summaryRowY(index: number): number {
const panel = layout.value.summary;
return panel.firstRowY + panel.rowGap * index;
}
</script>
<template>
<VExample id="graphic" title="Graphic overlay" desc="insight card · event markers · click focus">
<VExample id="graphic" title="Graphic overlay" desc="graphic · markers">
<VChart ref="chartRef" :option="option" autoresize>
<template #graphic>
<GGroup id="overlay-root">
<GRect
id="summary-card"
:x="layout.summary.x"
:y="layout.summary.y"
:width="layout.summary.width"
:height="layout.summary.height"
:r="10"
:fill="ui.cardBg"
:stroke="ui.cardStroke"
:line-width="1"
:z="40"
/>
<GText
id="summary-title"
:x="layout.summary.x + layout.summary.paddingX"
:y="layout.summary.titleY"
text="Weekly insight"
font="700 15px Manrope, sans-serif"
text-vertical-align="middle"
:fill="ui.cardTitle"
:z="50"
/>
<template v-for="(row, index) in summaryRows" :key="row.key">
<GText
:id="`summary-label-${row.key}`"
:x="layout.summary.x + layout.summary.paddingX"
:y="summaryRowY(index)"
:text="row.label"
font="500 10.5px Manrope, sans-serif"
text-vertical-align="middle"
:fill="ui.cardLabel"
:z="50"
/>
<GText
:id="`summary-value-${row.key}`"
:x="layout.summary.x + layout.summary.width - layout.summary.paddingX"
:y="summaryRowY(index)"
:text="row.value"
font="700 10.5px Manrope, sans-serif"
text-align="right"
text-vertical-align="middle"
:fill="row.key === 'focus' ? ui.cardFocus : ui.cardValue"
:z="50"
/>
</template>
<template v-for="marker in layout.markers" :key="marker.id">
<GLine
:id="`marker-line-${marker.id}`"
<GBezierCurve
:id="`marker-curve-${marker.id}`"
:x1="marker.x"
:y1="marker.y"
:x2="marker.anchorX"
:y2="marker.anchorY"
:stroke="marker.focused ? ui.focusLine : marker.color"
:line-width="marker.focused ? 2 : 1.5"
:cpx1="marker.cpx1"
:cpy1="marker.cpy1"
:cpx2="marker.cpx2"
:cpy2="marker.cpy2"
:stroke="marker.focused ? ui.focusLine : ui.lineSoft"
:line-width="marker.focused ? 1.6 : 1.1"
line-cap="round"
:z="20"
/>
<GRect
@@ -200,11 +206,12 @@ function summaryRowY(index: number): number {
:y="marker.bubbleY"
:width="marker.bubbleWidth"
:height="marker.bubbleHeight"
:r="15"
:r="10"
:fill="ui.bubbleBg"
:stroke="marker.focused ? marker.color : ui.bubbleStroke"
:line-width="marker.focused ? 1.5 : 1"
:line-width="marker.focused ? 1.25 : 1"
:z="40"
cursor="pointer"
@click="focusMarker(marker.id)"
/>
<GText
@@ -212,22 +219,24 @@ function summaryRowY(index: number): number {
:x="marker.textX"
:y="marker.textY"
:text="marker.label"
font="600 11px Manrope, sans-serif"
font="600 10px Manrope, sans-serif"
text-align="center"
text-vertical-align="middle"
:fill="marker.focused ? ui.bubbleTextFocus : ui.bubbleText"
:z="50"
cursor="pointer"
@click="focusMarker(marker.id)"
/>
<GCircle
:id="`marker-dot-${marker.id}`"
:cx="marker.x"
:cy="marker.y"
:r="marker.focused ? 8 : 6"
:r="marker.focused ? 7 : 5.5"
:fill="marker.color"
:stroke="ui.dotStroke"
:line-width="2"
:z="60"
cursor="pointer"
@click="focusMarker(marker.id)"
/>
</template>

View File

@@ -1,32 +1,22 @@
import type { GraphicOverlayUI } from "./types";
const LIGHT_TOKENS: GraphicOverlayUI = {
cardBg: "rgba(255,255,255,.93)",
cardStroke: "rgba(148,163,184,.45)",
cardTitle: "#0f172a",
cardLabel: "#64748b",
cardValue: "#1e293b",
cardFocus: "#0f766e",
bubbleBg: "rgba(255,255,255,.96)",
bubbleStroke: "rgba(148,163,184,.56)",
bubbleBg: "rgba(255,255,255,.94)",
bubbleStroke: "rgba(148,163,184,.48)",
bubbleText: "#334155",
bubbleTextFocus: "#0f172a",
focusLine: "#0f172a",
focusLine: "rgba(15,23,42,.62)",
lineSoft: "rgba(100,116,139,.52)",
dotStroke: "#ffffff",
};
const DARK_TOKENS: GraphicOverlayUI = {
cardBg: "rgba(5,12,27,.92)",
cardStroke: "rgba(71,85,105,.56)",
cardTitle: "#e2e8f0",
cardLabel: "#94a3b8",
cardValue: "#e2e8f0",
cardFocus: "#99f6e4",
bubbleBg: "rgba(7,16,34,.92)",
bubbleStroke: "rgba(71,85,105,.64)",
bubbleBg: "rgba(15,23,42,.92)",
bubbleStroke: "rgba(71,85,105,.6)",
bubbleText: "#cbd5e1",
bubbleTextFocus: "#f8fafc",
focusLine: "#e2e8f0",
focusLine: "rgba(226,232,240,.76)",
lineSoft: "rgba(148,163,184,.52)",
dotStroke: "#0b1220",
};

View File

@@ -20,27 +20,19 @@ export type OverlayMarker = EventMarker & {
textY: number;
anchorX: number;
anchorY: number;
};
export type SummaryData = {
peak: string;
low: string;
delta: string;
focus: string;
cpx1: number;
cpy1: number;
cpx2: number;
cpy2: number;
};
export type GraphicOverlayUI = {
cardBg: string;
cardStroke: string;
cardTitle: string;
cardLabel: string;
cardValue: string;
cardFocus: string;
bubbleBg: string;
bubbleStroke: string;
bubbleText: string;
bubbleTextFocus: string;
focusLine: string;
lineSoft: string;
dotStroke: string;
};
@@ -49,18 +41,16 @@ export type OverlayViewport = {
height: number;
};
export type SummaryPanelLayout = {
x: number;
y: number;
export type OverlayPlotLayout = {
left: number;
right: number;
top: number;
bottom: number;
width: number;
height: number;
paddingX: number;
titleY: number;
firstRowY: number;
rowGap: number;
};
export type GraphicOverlayLayout = {
summary: SummaryPanelLayout;
plot: OverlayPlotLayout;
markers: OverlayMarker[];
};

View File

@@ -1,43 +1,54 @@
import { computed, shallowRef } from "vue";
import { shallowRef } from "vue";
import type { EventMarker, SummaryData } from "./types";
import type { EventMarker } from "./types";
export const OVERLAY_DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] as const;
export const OVERLAY_DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
export const OVERLAY_Y_MAX = 220;
const OVERLAY_Y_MIN = 40;
const CAMPAIGN_BOOST = 14;
const CAMPAIGN_MARKER: EventMarker = {
id: "campaign",
dayIndex: 6,
title: "Campaign push",
color: "#ff9f0a",
};
const INITIAL_VALUES = [114, 182, 146, 92, 74, 112, 128];
function randomInt(min: number, max: number): number {
return Math.round(min + Math.random() * (max - min));
}
function clampValue(value: number): number {
return Math.max(OVERLAY_Y_MIN, Math.min(OVERLAY_Y_MAX, Math.round(value)));
}
function buildSemanticTrend(campaignEnabled: boolean): number[] {
const mon = randomInt(96, 124);
const tue = clampValue(mon + randomInt(46, 78));
const wed = clampValue(tue - randomInt(24, 42));
const thu = clampValue(Math.min(wed - randomInt(22, 38), randomInt(80, 98)));
const fri = clampValue(Math.min(thu - randomInt(6, 18), randomInt(62, 84)));
const sat = clampValue(fri + randomInt(26, 44));
const baseSun = clampValue(sat + randomInt(6, 20));
const sun = campaignEnabled ? clampValue(Math.max(baseSun + CAMPAIGN_BOOST, sat + 16)) : baseSun;
return [mon, tue, wed, thu, fri, sat, sun];
}
export function useGraphicOverlayData() {
const values = shallowRef([120, 200, 150, 80, 70, 110, 130]);
const values = shallowRef([...INITIAL_VALUES]);
const markers = shallowRef<EventMarker[]>([
{ id: "launch", dayIndex: 1, title: "Launch push", color: "#10b981" },
{ id: "incident", dayIndex: 3, title: "Checkout dip", color: "#ef4444" },
{ id: "recovery", dayIndex: 5, title: "Recovery", color: "#3b82f6" },
{ id: "launch", dayIndex: 1, title: "Launch spike", color: "#34c759" },
{ id: "incident", dayIndex: 3, title: "Checkout dip", color: "#ff453a" },
{ id: "recovery", dayIndex: 5, title: "Recovery rebound", color: "#0a84ff" },
]);
const focusedMarkerId = shallowRef(markers.value[0].id);
const summary = computed<SummaryData>(() => {
const data = values.value;
const peakValue = Math.max(...data);
const lowValue = Math.min(...data);
const peakIndex = data.indexOf(peakValue);
const lowIndex = data.indexOf(lowValue);
const weekDelta = data[data.length - 1] - data[0];
const focused = markers.value.find((marker) => marker.id === focusedMarkerId.value);
const focus = focused ? `${focused.title} · ${OVERLAY_DAYS[focused.dayIndex]}` : "None";
return {
peak: `${OVERLAY_DAYS[peakIndex]} ${peakValue}`,
low: `${OVERLAY_DAYS[lowIndex]} ${lowValue}`,
delta: `${weekDelta >= 0 ? "+" : ""}${weekDelta}`,
focus,
};
});
function randomizeTrend() {
values.value = values.value.map((value) => {
const drift = Math.round((Math.random() - 0.5) * 40);
return Math.max(40, Math.min(OVERLAY_Y_MAX, value + drift));
});
const campaignEnabled = markers.value.some((marker) => marker.id === CAMPAIGN_MARKER.id);
values.value = buildSemanticTrend(campaignEnabled);
}
function rotateFocus() {
@@ -54,31 +65,35 @@ export function useGraphicOverlayData() {
}
function toggleMarker() {
const extraMarker: EventMarker = {
id: "campaign",
dayIndex: 6,
title: "Campaign",
color: "#f59e0b",
};
const exists = markers.value.some((marker) => marker.id === extraMarker.id);
const exists = markers.value.some((marker) => marker.id === CAMPAIGN_MARKER.id);
if (exists) {
markers.value = markers.value.filter((marker) => marker.id !== extraMarker.id);
if (focusedMarkerId.value === extraMarker.id) {
markers.value = markers.value.filter((marker) => marker.id !== CAMPAIGN_MARKER.id);
values.value = values.value.map((value, index, data) => {
if (index !== CAMPAIGN_MARKER.dayIndex) {
return value;
}
return clampValue(Math.max(data[5] + 4, value - CAMPAIGN_BOOST));
});
if (focusedMarkerId.value === CAMPAIGN_MARKER.id) {
focusedMarkerId.value = markers.value[0]?.id ?? "";
}
return;
}
markers.value = [...markers.value, extraMarker];
markers.value = [...markers.value, CAMPAIGN_MARKER];
values.value = values.value.map((value, index, data) => {
if (index !== CAMPAIGN_MARKER.dayIndex) {
return value;
}
return clampValue(Math.max(value + CAMPAIGN_BOOST, data[5] + 16));
});
}
return {
values,
markers,
focusedMarkerId,
summary,
randomizeTrend,
rotateFocus,
focusMarker,

View File

@@ -13,28 +13,17 @@ const DEFAULT_VIEWPORT: OverlayViewport = {
};
const GRID_PERCENT = {
left: 9,
right: 6,
top: 30,
bottom: 14,
};
const PANEL = {
margin: 14,
minWidth: 228,
maxWidth: 284,
height: 132,
paddingX: 16,
titleOffsetY: 24,
firstRowOffsetY: 52,
rowGap: 20,
left: 8,
right: 5,
top: 18,
bottom: 12,
};
const BUBBLE = {
height: 30,
minWidth: 126,
maxWidth: 210,
padX: 14,
height: 26,
minWidth: 108,
maxWidth: 182,
padX: 10,
};
function clamp(value: number, min: number, max: number): number {
@@ -45,21 +34,12 @@ function intersects(a: Rect, b: Rect): boolean {
return a.x < b.x + b.width && a.x + a.width > b.x && a.y + a.height > b.y && a.y < b.y + b.height;
}
function expandRect(rect: Rect, gap: number): Rect {
return {
x: rect.x - gap,
y: rect.y - gap,
width: rect.width + gap * 2,
height: rect.height + gap * 2,
};
}
function bubbleWidth(label: string, viewportWidth: number): number {
const dynamicMax = Math.max(
BUBBLE.minWidth,
Math.min(BUBBLE.maxWidth, Math.round(viewportWidth * 0.24)),
Math.min(BUBBLE.maxWidth, Math.round(viewportWidth * 0.22)),
);
return clamp(Math.round(label.length * 6.7 + BUBBLE.padX * 2), BUBBLE.minWidth, dynamicMax);
return clamp(Math.round(label.length * 6.4 + BUBBLE.padX * 2), BUBBLE.minWidth, dynamicMax);
}
export function buildGraphicOverlayLayout(options: {
@@ -78,28 +58,16 @@ export function buildGraphicOverlayLayout(options: {
const viewportWidth = Math.max(options.viewport.width, DEFAULT_VIEWPORT.width * 0.55);
const viewportHeight = Math.max(options.viewport.height, DEFAULT_VIEWPORT.height * 0.58);
const panelWidth = clamp(Math.round(viewportWidth * 0.25), PANEL.minWidth, PANEL.maxWidth);
const summary = {
x: viewportWidth - panelWidth - PANEL.margin,
y: PANEL.margin,
width: panelWidth,
height: PANEL.height,
paddingX: PANEL.paddingX,
titleY: PANEL.margin + PANEL.titleOffsetY,
firstRowY: PANEL.margin + PANEL.firstRowOffsetY,
rowGap: PANEL.rowGap,
};
const plotLeft = (viewportWidth * GRID_PERCENT.left) / 100;
const plotRight = viewportWidth - (viewportWidth * GRID_PERCENT.right) / 100;
const plotTop = (viewportHeight * GRID_PERCENT.top) / 100;
const plotBottom = viewportHeight - (viewportHeight * GRID_PERCENT.bottom) / 100;
const plotWidth = plotRight - plotLeft;
const plotHeight = plotBottom - plotTop;
const maxBubbleY = plotBottom - BUBBLE.height - 6;
const maxBubbleY = plotBottom - BUBBLE.height - 4;
const placedRects: Rect[] = [];
const reservedRects: Rect[] = [expandRect({ ...summary }, 8)];
const reservedRects: Rect[] = [];
const laneBySide = { left: 0, right: 0 };
const overlayMarkers = markers.map((marker) => {
@@ -112,14 +80,14 @@ export function buildGraphicOverlayLayout(options: {
const y = Math.round(plotTop + plotHeight * (1 - value / options.yMax));
const bubbleWidthPx = bubbleWidth(label, plotWidth);
const side = x > summary.x - 22 ? "left" : "right";
const side = x > plotLeft + plotWidth * 0.58 ? "left" : "right";
const lane = side === "left" ? laneBySide.left++ : laneBySide.right++;
const laneOffset = lane * 28;
const laneOffset = lane * 22;
const preferredLeftX = x - bubbleWidthPx - 16;
const preferredRightX = x + 16;
const preferredAboveY = y - 42 - laneOffset;
const preferredBelowY = y + 10 + laneOffset;
const preferredLeftX = x - bubbleWidthPx - 12;
const preferredRightX = x + 12;
const preferredAboveY = y - 36 - laneOffset;
const preferredBelowY = y + 7 + laneOffset;
const rawCandidates =
side === "left"
@@ -137,8 +105,8 @@ export function buildGraphicOverlayLayout(options: {
];
const candidates = rawCandidates.map((candidate) => {
const bubbleX = clamp(candidate.x, 8, Math.max(8, viewportWidth - bubbleWidthPx - 8));
const bubbleY = clamp(candidate.y, 8, maxBubbleY);
const bubbleX = clamp(candidate.x, 6, Math.max(6, viewportWidth - bubbleWidthPx - 6));
const bubbleY = clamp(candidate.y, 6, maxBubbleY);
return {
bubbleX,
bubbleY,
@@ -158,6 +126,12 @@ export function buildGraphicOverlayLayout(options: {
const anchorX =
picked.bubbleX + bubbleWidthPx / 2 < x ? picked.bubbleX + bubbleWidthPx : picked.bubbleX;
const anchorY = clamp(y, picked.bubbleY + 4, picked.bubbleY + BUBBLE.height - 4);
const bubbleCenterY = picked.bubbleY + BUBBLE.height / 2;
const direction = anchorX >= x ? 1 : -1;
const cpx1 = Math.round(x + direction * 10);
const cpy1 = Math.round(y + (bubbleCenterY > y ? 7 : -7));
const cpx2 = Math.round(anchorX - direction * 14);
const cpy2 = Math.round(anchorY + (bubbleCenterY > anchorY ? -2 : 2));
return {
...marker,
@@ -175,11 +149,22 @@ export function buildGraphicOverlayLayout(options: {
textY: picked.bubbleY + BUBBLE.height / 2,
anchorX,
anchorY,
cpx1,
cpy1,
cpx2,
cpy2,
};
});
return {
summary,
plot: {
left: plotLeft,
right: plotRight,
top: plotTop,
bottom: plotBottom,
width: plotWidth,
height: plotHeight,
},
markers: overlayMarkers,
};
}