mirror of
https://github.com/ecomfe/vue-echarts.git
synced 2026-03-13 08:41:05 +08:00
feat(demo): simplify graphic overlay markers
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user