update chart

This commit is contained in:
Timi
2026-08-12 00:24:12 +08:00
parent 9c30891f2c
commit 171aee1602
17 changed files with 1554 additions and 697 deletions
+247
View File
@@ -0,0 +1,247 @@
<template>
<div class="chart-range-slider">
<div ref="trackRef" class="track" @pointerdown="handleTrackPointerDown">
<div
class="selection"
:style="selectionStyle"
@pointerdown.stop="handleSelectionPointerDown"
/>
<button
class="thumb start"
:style="startThumbStyle"
type="button"
aria-label="调整开始日期"
@pointerdown.stop="event => handleThumbPointerDown('start', event)"
/>
<button
class="thumb end"
:style="endThumbStyle"
type="button"
aria-label="调整结束日期"
@pointerdown.stop="event => handleThumbPointerDown('end', event)"
/>
</div>
<div class="label" v-text="label" />
</div>
</template>
<script setup lang="ts">
type DragMode = "start" | "end" | "range";
const props = withDefaults(defineProps<{
label?: string;
max: number;
min?: number;
minRange?: number;
modelValue: number[];
}>(), {
label: "",
minRange: 0,
min: 0
});
const emit = defineEmits<{
"update:modelValue": [value: number[]];
change: [value: number[]];
}>();
const trackRef = ref<HTMLElement>();
const draggingMode = ref<DragMode>();
const dragStartX = ref(0);
const dragStartValue = ref<[number, number]>([0, 0]);
const safeMin = computed(() => Math.min(props.min, props.max));
const safeMax = computed(() => Math.max(props.min, props.max));
const valueRange = computed(() => Math.max(1, safeMax.value - safeMin.value));
const safeMinRange = computed(() => Math.min(Math.max(0, Math.round(props.minRange)), valueRange.value));
const normalizedValue = computed<[number, number]>(() => normalizeRange(props.modelValue));
const startPercent = computed(() => valueToPercent(normalizedValue.value[0]));
const endPercent = computed(() => valueToPercent(normalizedValue.value[1]));
const selectionStyle = computed(() => ({
left: `${startPercent.value}%`,
width: `${Math.max(0, endPercent.value - startPercent.value)}%`
}));
const startThumbStyle = computed(() => ({
left: `${startPercent.value}%`
}));
const endThumbStyle = computed(() => ({
left: `${endPercent.value}%`
}));
onBeforeUnmount(() => {
stopDocumentDrag();
});
function handleThumbPointerDown(mode: "start" | "end", event: PointerEvent): void {
startDocumentDrag(mode, event);
}
function handleSelectionPointerDown(event: PointerEvent): void {
startDocumentDrag("range", event);
}
function handleTrackPointerDown(event: PointerEvent): void {
const value = pointToValue(event.clientX);
const [start, end] = normalizedValue.value;
const targetMode: DragMode = Math.abs(value - start) <= Math.abs(value - end) ? "start" : "end";
updateByMode(targetMode, value);
startDocumentDrag(targetMode, event);
}
function startDocumentDrag(mode: DragMode, event: PointerEvent): void {
draggingMode.value = mode;
dragStartX.value = event.clientX;
dragStartValue.value = normalizedValue.value;
window.addEventListener("pointermove", handleDocumentPointerMove);
window.addEventListener("pointerup", handleDocumentPointerUp);
window.addEventListener("pointercancel", handleDocumentPointerUp);
}
function handleDocumentPointerMove(event: PointerEvent): void {
const mode = draggingMode.value;
if (!mode) {
return;
}
if (mode === "range") {
const nextValue = dragStartValue.value[0] + pointDeltaToStep(event.clientX - dragStartX.value);
const width = dragStartValue.value[1] - dragStartValue.value[0];
const start = clamp(nextValue, safeMin.value, safeMax.value - width);
commitRange([start, start + width]);
return;
}
updateByMode(mode, pointToValue(event.clientX));
}
function handleDocumentPointerUp(): void {
stopDocumentDrag();
}
function stopDocumentDrag(): void {
window.removeEventListener("pointermove", handleDocumentPointerMove);
window.removeEventListener("pointerup", handleDocumentPointerUp);
window.removeEventListener("pointercancel", handleDocumentPointerUp);
draggingMode.value = undefined;
}
function updateByMode(mode: "start" | "end", value: number): void {
const [start, end] = normalizedValue.value;
if (mode === "start") {
commitRange([Math.min(value, end - safeMinRange.value), end]);
return;
}
commitRange([start, Math.max(start + safeMinRange.value, value)]);
}
function commitRange(value: [number, number]): void {
const normalized = normalizeRange(value);
emit("update:modelValue", normalized);
emit("change", normalized);
}
function normalizeRange(value: number[]): [number, number] {
const rawStart = value[0] ?? safeMin.value;
const rawEnd = value[1] ?? rawStart;
const start = clamp(Math.round(Math.min(rawStart, rawEnd)), safeMin.value, safeMax.value);
const end = clamp(Math.round(Math.max(rawStart, rawEnd)), start + safeMinRange.value, safeMax.value);
if (safeMax.value < start + safeMinRange.value) {
return [safeMin.value, safeMax.value];
}
return [Math.min(start, end - safeMinRange.value), end];
}
function pointToValue(clientX: number): number {
const rect = trackRef.value?.getBoundingClientRect();
if (!rect || rect.width <= 0) {
return safeMin.value;
}
const percent = clamp((clientX - rect.left) / rect.width, 0, 1);
return Math.round(safeMin.value + percent * valueRange.value);
}
function pointDeltaToStep(deltaX: number): number {
const rect = trackRef.value?.getBoundingClientRect();
if (!rect || rect.width <= 0) {
return 0;
}
return Math.round(deltaX / rect.width * valueRange.value);
}
function valueToPercent(value: number): number {
return (clamp(value, safeMin.value, safeMax.value) - safeMin.value) / valueRange.value * 100;
}
function clamp(value: number, min: number, max: number): number {
return Math.min(Math.max(value, min), max);
}
</script>
<style scoped lang="less">
.chart-range-slider {
padding: .25rem .25rem 0 .25rem;
.track {
height: 2.25rem;
display: flex;
position: relative;
align-items: center;
touch-action: none;
&::before {
left: 0;
right: 0;
height: .25rem;
content: "";
position: absolute;
background: rgba(148, 163, 184, .24);
border-radius: 999rem;
}
}
.selection {
height: .25rem;
position: absolute;
background: var(--td-brand-color);
border-radius: 999rem;
touch-action: none;
}
.thumb {
top: 50%;
margin: 0;
padding: 0;
width: 1.25rem;
height: 1.25rem;
border: .125rem solid var(--td-brand-color);
position: absolute;
background: #fff;
transform: translate(-50%, -50%);
box-shadow: 0 .125rem .5rem rgba(15, 23, 42, .18);
border-radius: 999rem;
touch-action: none;
}
.label {
color: #52607a;
font-size: .75rem;
text-align: center;
white-space: nowrap;
}
}
:global(html.theme-dark) {
.chart-range-slider {
.track::before {
background: rgba(148, 163, 184, .28);
}
.thumb {
background: #10161f;
box-shadow: 0 .125rem .5rem rgba(2, 6, 23, .38);
}
.label {
color: #94a3b8;
}
}
}
</style>