diff --git a/bun.lock b/bun.lock index 36839ca..b147476 100644 --- a/bun.lock +++ b/bun.lock @@ -186,10 +186,12 @@ "packages/temp_frontend": { "name": "@cvsa/cvsa-temp", "dependencies": { + "@alikia/dark-theme-hook": "^1.0.2", "@elysiajs/eden": "^1.4.1", "@nivo/core": "^0.99.0", "@nivo/line": "^0.99.0", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", "@react-router/node": "^7.7.1", "@react-router/serve": "^7.7.1", "class-variance-authority": "^0.7.1", @@ -221,6 +223,8 @@ }, }, "packages": { + "@alikia/dark-theme-hook": ["@alikia/dark-theme-hook@1.0.2", "", { "dependencies": { "react": "^18.3.1" } }, "sha512-OloatZRefHB7Ey3zjhfsKFZoHbfzewPfOeEwA7q9zDXViNKGyTA4CiCF3US5vzqfhpR16wpYcPSmpVabKI3MYg=="], + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], "@antfu/install-pkg": ["@antfu/install-pkg@1.1.0", "", { "dependencies": { "package-manager-detector": "^1.3.0", "tinyexec": "^1.0.1" } }, "sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ=="], @@ -667,10 +671,36 @@ "@rabbit-company/argon2id": ["@rabbit-company/argon2id@2.1.0", "", { "peerDependencies": { "typescript": "^5.6.2" } }, "sha512-X/kt89qjmS9+Zh+DYCGcWeTwHa4C8vY8T3EnSma+vWj7spMzAYX4F8vmGUkny9hygpTOeC/yXwAUdJAfJ52H+w=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + "@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + "@react-router/dev": ["@react-router/dev@7.9.1", "", { "dependencies": { "@babel/core": "^7.27.7", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@npmcli/package-json": "^4.0.1", "@react-router/node": "7.9.1", "arg": "^5.0.1", "babel-dead-code-elimination": "^1.0.6", "chokidar": "^4.0.0", "dedent": "^1.5.3", "es-module-lexer": "^1.3.1", "exit-hook": "2.2.1", "isbot": "^5.1.11", "jsesc": "3.0.2", "lodash": "^4.17.21", "pathe": "^1.1.2", "picocolors": "^1.1.1", "prettier": "^3.6.2", "react-refresh": "^0.14.0", "semver": "^7.3.7", "set-cookie-parser": "^2.6.0", "tinyglobby": "^0.2.14", "valibot": "^0.41.0", "vite-node": "^3.2.2" }, "peerDependencies": { "@react-router/serve": "^7.9.1", "@vitejs/plugin-rsc": "*", "react-router": "^7.9.1", "typescript": "^5.1.0", "vite": "^5.1.0 || ^6.0.0 || ^7.0.0", "wrangler": "^3.28.2 || ^4.0.0" }, "optionalPeers": ["@react-router/serve", "@vitejs/plugin-rsc", "typescript", "wrangler"], "bin": { "react-router": "bin.js" } }, "sha512-fW/qubsdHq1nsufHPLpXa6hiNvXXV9JBtWqRlJ02OOhFeaWERZw4rGoHjG1DCg8/QTTadgbzplmP97ZnzWPkcA=="], "@react-router/express": ["@react-router/express@7.9.1", "", { "dependencies": { "@react-router/node": "7.9.1" }, "peerDependencies": { "express": "^4.17.1 || ^5", "react-router": "7.9.1", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-d1sfsD3AJXZj+C5k3jAmxAD3vJXGfoh3lNmtSwxp0NdZFHI54zPC5S9o80cy3P8p6Gc7XzSEQJYk9k7fAM/AIw=="], @@ -1857,6 +1887,8 @@ "longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="], + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + "loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="], "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], @@ -2775,6 +2807,8 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], + "@alikia/dark-theme-hook/react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], + "@antfu/install-pkg/tinyexec": ["tinyexec@1.0.1", "", {}, "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw=="], "@babel/code-frame/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], @@ -2991,6 +3025,8 @@ "log-symbols/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "loose-envify/js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], diff --git a/packages/temp_frontend/app/components/Chart.tsx b/packages/temp_frontend/app/components/Chart.tsx index 259dd8c..b1438ac 100644 --- a/packages/temp_frontend/app/components/Chart.tsx +++ b/packages/temp_frontend/app/components/Chart.tsx @@ -1,22 +1,109 @@ import { useState, useRef, useMemo, useCallback, useEffect } from "react"; +import { HOUR, DAY, WEEK } from "@core/lib"; + +export type TimeRange = "6h" | "1d" | "7d" | "30d" | "3mo" | "6mo" | "1y" | "all"; + +const TIME_RANGES = { + "6h": 6 * HOUR, + "1d": DAY, + "7d": 7 * DAY, + "30d": 30 * DAY, + "3mo": 90 * DAY, + "6mo": 180 * DAY, + "1y": 365 * DAY, + all: (maxTime: number, minTime: number) => maxTime - minTime, +}; + +function getWindowSize(timeRange: TimeRange, { maxTime, minTime }: { maxTime: number; minTime: number }) { + const val = TIME_RANGES[timeRange]; + if (typeof val === "function") return val(maxTime, minTime); + return val ?? 7 * DAY; +} + +const leftPad = 10; +const rightPad = 10; + +function calculateXAxisAverageAcceleration(lastPointerPositions: { x: number; time: number }[]) { + if (lastPointerPositions.length < 3) { + return 0; + } + + const accelerations = []; + + for (let i = 2; i < lastPointerPositions.length; i++) { + const point1 = lastPointerPositions[i - 2]; + const point2 = lastPointerPositions[i - 1]; + const point3 = lastPointerPositions[i]; + + const deltaTime1 = (point2.time - point1.time) / 1000; + const deltaTime2 = (point3.time - point2.time) / 1000; + if (deltaTime1 === 0 || deltaTime2 === 0) { + continue; + } + + const velocity1 = (point2.x - point1.x) / deltaTime1; + const velocity2 = (point3.x - point2.x) / deltaTime2; + + const averageDeltaTime = (deltaTime1 + deltaTime2) / 2; + const acceleration = (velocity2 - velocity1) / averageDeltaTime; + + accelerations.push(acceleration); + } + + if (accelerations.length === 0) { + return 0; + } + + const sum = accelerations.reduce((acc, val) => acc + val, 0); + const averageAcceleration = sum / accelerations.length; + + return averageAcceleration; +} + +interface CharProps extends React.HTMLAttributes { + data: { timestamp: number; value: number }[]; + width?: string; + height?: number; + accentColor?: string; + smoothInterpolation?: boolean; + timeRange?: "6h" | "1d" | "7d" | "30d" | "3mo" | "6mo" | "1y" | "all"; + outside?: boolean; + ref?: React.Ref; + setCurrentData?: (data: string) => void; + setCurrentDate?: (date: string) => void; +} export const TimeSeriesChart = ({ data = [], width = "100%", height = 300, accentColor = "#007AFF", - showGrid = true, smoothInterpolation = true, - timeRange = "auto", // '6h', '1d', '7d', '30d', 'auto' -}: { - data: { timestamp: number; value: number }[]; - width?: string; - height?: number; - accentColor?: string; - showGrid?: boolean; - smoothInterpolation?: boolean; - timeRange?: "6h" | "1d" | "7d" | "30d" | "auto"; -}) => { + timeRange = "7d", + outside = false, + ref = null, + setCurrentData, + setCurrentDate, + ...rest +}: CharProps) => { + const [yLabelWidth, setYLabelWidth] = useState(0); + const widthProbeRef = useRef(null); + const sortedData = useMemo(() => [...data].sort((a, b) => a.timestamp - b.timestamp), [data]); + const globalMaxValue = useMemo( + () => (sortedData.length > 0 ? sortedData[sortedData.length - 1].value : 0), + [sortedData], + ); + // Y-axis range state for lazy adjustment + const [yAxisRange, setYAxisRange] = useState({ min: 0, max: 0 }); + const [yAxisAnimation, setYAxisAnimation] = useState<{ + isAnimating: boolean; + startMin: number; + startMax: number; + targetMin: number; + targetMax: number; + startTimestamp: number; + } | null>(null); + const yAxisAnimationRef = useRef(null); const svgRef = useRef(null); const containerRef = useRef(null); const [currentPosition, setCurrentPosition] = useState<{ @@ -24,15 +111,102 @@ export const TimeSeriesChart = ({ y: number; data: { timestamp: number; value: number } | null; } | null>(null); - const [isDragging, setIsDragging] = useState(false); const [dragStartX, setDragStartX] = useState(0); - const [viewBox, setViewBox] = useState({ start: 0, end: 1 }); + const [timeWindow, setTimeWindow] = useState({ startTime: 0, endTime: 0 }); const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); - const [isTouchActive, setIsTouchActive] = useState(false); const [longPressTimer, setLongPressTimer] = useState(null); - const [touchStartPosition, setTouchStartPosition] = useState<{ x: number; y: number } | null>(null); + const animationRef = useRef(null); + const [lastPointerPositions, setLastPointerPositions] = useState<{ x: number; y: number; time: number }[]>([]); + const [pointerStartPosition, setPointerStartPosition] = useState<{ x: number; y: number; time: number } | null>( + null, + ); + + const visibleData = useMemo(() => { + if (data.length === 0 || timeWindow.startTime === 0) return []; + + let firstIndex = -1; + let lastIndex = -1; + + for (let i = 0; i < sortedData.length; i++) { + const t = sortedData[i].timestamp; + if (firstIndex === -1 && t >= timeWindow.startTime) firstIndex = i; + if (t <= timeWindow.endTime) lastIndex = i; + if (t > timeWindow.endTime && lastIndex !== -1) break; + } + + if (firstIndex === -1 && lastIndex === -1) return []; + + const from = Math.max(0, (firstIndex === -1 ? 0 : firstIndex) - leftPad); + const to = Math.min(sortedData.length - 1, (lastIndex === -1 ? sortedData.length - 1 : lastIndex) + rightPad); + + if (from > to) return []; + + return sortedData.slice(from, to + 1); + }, [data, timeWindow, sortedData]); + + useEffect(() => { + if (!setCurrentData || !setCurrentDate) return; + + const haveCurrent = + currentPosition && currentPosition.data && currentPosition.data.timestamp && currentPosition.data.value; + + if (haveCurrent) { + const { timestamp, value } = currentPosition.data!; + const month = new Date(timestamp).getMonth() + 1; + const day = new Date(timestamp).getDate(); + const hour = new Date(timestamp).getHours().toString().padStart(2, "0"); + const minute = new Date(timestamp).getMinutes().toString().padStart(2, "0"); + setCurrentData(value.toLocaleString()); + setCurrentDate(`${month}月${day}日 ${hour}:${minute}`); + return; + } + + if (Array.isArray(sortedData) && sortedData.length > 0) { + const oldest = visibleData.length ? visibleData[leftPad - 1] : sortedData[0]; + const newest = visibleData.length + ? visibleData[visibleData.length - rightPad] + : sortedData[sortedData.length - 1]; + const increment = newest.value - oldest.value; + const year = new Date(oldest.timestamp).getFullYear(); + const month = new Date(oldest.timestamp).getMonth() + 1; + const day = new Date(oldest.timestamp).getDate(); + const hour = new Date(oldest.timestamp).getHours().toString().padStart(2, "0"); + const minute = new Date(oldest.timestamp).getMinutes().toString().padStart(2, "0"); + const newestYear = new Date(newest.timestamp).getFullYear(); + const newestMonth = new Date(newest.timestamp).getMonth() + 1; + const newestDay = new Date(newest.timestamp).getDate(); + const newestHour = new Date(newest.timestamp).getHours().toString().padStart(2, "0"); + const newestMinute = new Date(newest.timestamp).getMinutes().toString().padStart(2, "0"); + const timeRange = newest.timestamp - oldest.timestamp; + if (year !== newestYear) { + setCurrentDate(`${year}年${month}月${day}日–${newestYear}年${newestMonth}月${newestDay}日`); + } else if (month !== newestMonth || timeRange > DAY) { + setCurrentDate(`${year}年 ${month}月${day}日–${newestMonth}月${newestDay}日`); + } else if (day !== newestDay) { + setCurrentDate( + `${month}月${day}日 ${hour}:${minute}–${newestMonth}月${newestDay}日 ${newestHour}:${newestMinute}`, + ); + } else { + setCurrentDate(`${month}月${day}日 ${hour}:${minute}–${newestHour}:${newestMinute}`); + } + setCurrentData("+" + increment.toLocaleString()); + } + }, [ + currentPosition, + currentPosition?.data?.timestamp, + currentPosition?.data?.value, + sortedData, + setCurrentData, + setCurrentDate, + visibleData, + ]); + + useEffect(() => { + if (!widthProbeRef.current) return; + const { width } = widthProbeRef.current.getBoundingClientRect(); + setYLabelWidth(width); + }, [widthProbeRef.current]); - // 响应式尺寸处理 useEffect(() => { const updateDimensions = () => { if (containerRef.current) { @@ -46,112 +220,198 @@ export const TimeSeriesChart = ({ return () => window.removeEventListener("resize", updateDimensions); }, []); - // 格式化时间标签 - const formatTimeLabel = useCallback((timestamp: number, range: "6h" | "1d" | "7d" | "30d" | "auto") => { - const date = new Date(timestamp); - const now = new Date(); - const isToday = date.toDateString() === now.toDateString(); + useEffect(() => { + if (svgRef.current) { + const { width: svgWidth, height: svgHeight } = svgRef.current.getBoundingClientRect(); + setDimensions({ width: svgWidth, height: svgHeight }); + } + }, [svgRef.current?.getBoundingClientRect().height]); - switch (range) { - case "6h": - return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); - case "1d": - return isToday ? "今天" : date.toLocaleDateString([], { month: "short", day: "numeric" }); - case "7d": - return date.toLocaleDateString([], { weekday: "short" }); - case "30d": - return date.toLocaleDateString([], { month: "short", day: "numeric" }); - default: - return date.toLocaleDateString([], { month: "short", day: "numeric" }); + useEffect(() => { + if (!outside) return; + setCurrentPosition(null); + }, [outside]); + + useEffect(() => { + if (data.length === 0) return; + + const minTime = sortedData[0].timestamp; + const maxTime = sortedData[sortedData.length - 1].timestamp; + + const windowSize = getWindowSize(timeRange, { maxTime, minTime }); + + const initialEndTime = maxTime; + const initialStartTime = Math.max(minTime, initialEndTime - windowSize); + + setTimeWindow({ + startTime: initialStartTime, + endTime: initialEndTime, + }); + + if (visibleValues.length > 0) { + const targetMin = Math.max(0, visibleMin); + const targetMax = visibleMax; + + const buffer = (targetMax - targetMin) * 0.3; + + // Align to nice numbers + const alignedRange = alignRangeToNiceNumbers(targetMin - buffer, targetMax + buffer); + + setYAxisRange({ + min: alignedRange.min, + max: alignedRange.max, + }); + } + }, [data, timeRange]); + + // Cleanup animations on unmount + useEffect(() => { + return () => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + if (yAxisAnimationRef.current) { + cancelAnimationFrame(yAxisAnimationRef.current); + } + }; + }, []); + + const formatTimeLabel = useCallback((timestamp: number, timeRangeMs: number) => { + const date = new Date(timestamp); + + if (timeRangeMs <= 6 * HOUR) { + return date.toLocaleTimeString([], { hourCycle: "h23", hour: "2-digit", minute: "2-digit" }); + } else if (timeRangeMs <= DAY) { + return date.toLocaleTimeString([], { hourCycle: "h23", hour: "2-digit", minute: "2-digit" }); + } else if (timeRangeMs <= 7 * DAY) { + return date.toLocaleDateString([], { month: "numeric", day: "numeric" }); + } else { + return date.toLocaleDateString([], { month: "numeric", day: "numeric" }); } }, []); - // 处理数据范围和时间间隔 - const { xScale, yScale, visibleData, timeTicks, yTicks } = useMemo(() => { - if (!data.length || !dimensions.width) return {}; + const visibleValues = useMemo(() => visibleData.map((d) => d.value), [visibleData]); - const visibleDataPoints = data.filter((point, index) => { - const progress = index / (data.length - 1); - return progress >= viewBox.start && progress <= viewBox.end; - }); + const visibleMax = visibleValues[visibleValues.length - 1]; - if (!visibleDataPoints.length) return {}; + const visibleMin = visibleValues[0]; - // 计算Y轴范围(带一些边距) - const values = visibleDataPoints.map((d) => d.value); - const minValue = Math.min(...values); - const maxValue = Math.max(...values); - const valueRange = maxValue - minValue; - const padding = valueRange * 0.1; + const generateNiceTicks = (min: number, max: number, targetTickCount: number = 4) => { + const range = max - min; + const roughStep = range / (targetTickCount - 1); + + const magnitude = Math.pow(10, Math.floor(Math.log10(roughStep))); + const normalizedStep = roughStep / magnitude; + + let niceStep; + if (normalizedStep <= 1) { + niceStep = 1; + } else if (normalizedStep <= 2) { + niceStep = 2; + } else if (normalizedStep <= 5) { + niceStep = 5; + } else { + niceStep = 10; + } + + niceStep *= magnitude; + + // Generate ticks + const ticks = []; + const firstTick = Math.floor(min / niceStep) * niceStep; + const lastTick = Math.ceil(max / niceStep) * niceStep; + + for (let value = firstTick; value <= lastTick; value += niceStep) { + if (value >= min && value <= max) { + ticks.push(value); + } + } + + return ticks; + }; + + const { xScale, yScale, timeTicks, yTicks } = useMemo(() => { + if (!visibleData.length || !dimensions.width || yAxisRange.min === yAxisRange.max) return {}; const yScale = (value: number) => { - const chartHeight = dimensions.height - 60; // 为标签留出空间 - return chartHeight - ((value - (minValue - padding)) / (maxValue - minValue + 2 * padding)) * chartHeight + 20; + const chartHeight = dimensions.height - 60; + const normalizedValue = (value - yAxisRange.min) / (yAxisRange.max - yAxisRange.min); + return chartHeight - normalizedValue * chartHeight + 20; }; - // X轴比例尺 - const xScale = (index: number) => { - const totalPoints = visibleDataPoints.length; - return (index / (totalPoints - 1)) * (dimensions.width - 60) + 40; + const xScale = (timestamp: number) => { + const timeRange = timeWindow.endTime - timeWindow.startTime; + const timePosition = timestamp - timeWindow.startTime; + const xPosition = (timePosition / timeRange) * (dimensions.width - yLabelWidth - 10) + yLabelWidth + 5; + return xPosition; }; - // 生成时间刻度 const generateTimeTicks = () => { - const tickCount = Math.min(6, Math.floor(dimensions.width / 80)); - const ticks = []; + const timeRange = timeWindow.endTime - timeWindow.startTime; + let tickInterval: number; - for (let i = 0; i < tickCount; i++) { - const dataIndex = Math.floor((i / (tickCount - 1)) * (visibleDataPoints.length - 1)); - if (visibleDataPoints[dataIndex]) { + if (timeRange <= 6 * HOUR) { + tickInterval = HOUR; + } else if (timeRange <= DAY) { + tickInterval = 4 * HOUR; + } else if (timeRange <= 7 * DAY) { + tickInterval = DAY; + } else if (timeRange <= 30 * DAY) { + tickInterval = 7 * DAY; + } else if (timeRange <= 90 * DAY) { + tickInterval = 3 * WEEK; + } else { + tickInterval = 30 * DAY; + } + + const ticks = []; + let currentTick = Math.ceil(timeWindow.startTime / tickInterval) * tickInterval - tickInterval; + + while (currentTick <= timeWindow.endTime + tickInterval) { + const x = xScale(currentTick); + if (x >= -30 && x <= dimensions.width + 30) { ticks.push({ - x: xScale(dataIndex), - timestamp: visibleDataPoints[dataIndex].timestamp, - label: formatTimeLabel(visibleDataPoints[dataIndex].timestamp, timeRange), + x, + timestamp: currentTick, + label: formatTimeLabel(currentTick, timeRange), }); } + currentTick += tickInterval; } + return ticks; }; - // 生成Y轴刻度 const generateYTicks = () => { - const tickCount = 4; - const ticks = []; - - for (let i = 0; i <= tickCount; i++) { - const value = minValue - padding + (maxValue + padding - (minValue - padding)) * (i / tickCount); - const y = yScale(value); - ticks.push({ - y, - value: Math.round(value * 100) / 100, // 保留两位小数 - }); - } - return ticks; + const ticks = generateNiceTicks(yAxisRange.min, yAxisRange.max, 6); + return ticks.map((value) => ({ + y: yScale(value), + value: value, + })); }; return { xScale, yScale, - visibleData: visibleDataPoints, timeTicks: generateTimeTicks(), yTicks: generateYTicks(), }; - }, [data, dimensions, viewBox, timeRange]); + }, [visibleData, dimensions, timeWindow, timeRange, yLabelWidth, yAxisRange]); - // 生成平滑路径 const generatePath = useCallback(() => { if (!visibleData || !xScale || !yScale) return ""; if (!smoothInterpolation || visibleData.length < 3) { - // 直线连接 return visibleData - .map((point, index) => `${index === 0 ? "M" : "L"} ${xScale(index)} ${yScale(point.value)}`) + .map( + (point) => + `${point === visibleData[0] ? "M" : "L"} ${xScale(point.timestamp)} ${yScale(point.value)}`, + ) .join(" "); } - // Catmull-Rom 平滑曲线 - const points = visibleData.map((point, index) => ({ - x: xScale(index), + const points = visibleData.map((point) => ({ + x: xScale(point.timestamp), y: yScale(point.value), })); @@ -163,7 +423,7 @@ export const TimeSeriesChart = ({ const p2 = points[i + 1]; const p3 = points[Math.min(points.length - 1, i + 2)]; - const tension = 0.5; + const tension = Math.min(0.0005 * (p2.x - p1.x) ** 1.8, 1); const x1 = p1.x + ((p2.x - p0.x) / 6) * tension; const y1 = p1.y + ((p2.y - p0.y) / 6) * tension; const x2 = p2.x - ((p3.x - p1.x) / 6) * tension; @@ -175,306 +435,444 @@ export const TimeSeriesChart = ({ return path; }, [visibleData, xScale, yScale, smoothInterpolation]); - // 更新光标位置和对应数据点 const updateCursorPosition = useCallback( (x: number) => { if (!visibleData || !xScale) return; - // 找到最近的数据点 - let closestIndex = 0; + let closestPoint = visibleData[0]; let minDistance = Infinity; - visibleData.forEach((point, index) => { - const pointX = xScale(index); + visibleData.forEach((point, idx) => { + if ( + idx < leftPad|| + (idx > visibleData.length - rightPad + 1 && + visibleData[visibleData.length - 1].timestamp > timeWindow.endTime) + ) + return; + const pointX = xScale(point.timestamp); const distance = Math.abs(pointX - x); if (distance < minDistance) { minDistance = distance; - closestIndex = index; + closestPoint = point; } }); - if (minDistance < 50) { - // 灵敏度阈值 - const point = visibleData[closestIndex]; - setCurrentPosition({ - x: xScale(closestIndex), - y: yScale(point.value), - data: point, - }); - } else { - setCurrentPosition(null); - } + setCurrentPosition({ + x: xScale(closestPoint.timestamp), + y: yScale(closestPoint.value), + data: closestPoint, + }); }, [visibleData, xScale, yScale], ); - // 鼠标事件处理 - const handleMouseDown = useCallback( - (e: React.MouseEvent) => { - console.log("mouse down"); + const handlePointerDown = useCallback( + (e: React.PointerEvent) => { if (!svgRef.current) return; const rect = svgRef.current.getBoundingClientRect(); const x = e.clientX - rect.left; - setIsDragging(true); setDragStartX(x); - updateCursorPosition(x); - }, - [updateCursorPosition], - ); + setPointerStartPosition({ x, y: e.clientY - rect.top, time: Date.now() }); - const handleMouseMove = useCallback( - (e: React.MouseEvent) => { - console.log("mouse move"); - if (!svgRef.current) return; - - const rect = svgRef.current.getBoundingClientRect(); - const x = e.clientX - rect.left; - - if (isDragging) { - // 滚动逻辑 - const deltaX = x - dragStartX; - if (Math.abs(deltaX) > 10) { - const dragSpeed = 0.02; - const newStart = Math.max(0, viewBox.start - deltaX * dragSpeed); - const newEnd = Math.min(1, viewBox.end - deltaX * dragSpeed); - - if (newEnd - newStart === viewBox.end - viewBox.start) { - setViewBox({ start: newStart, end: newEnd }); - } - setDragStartX(x); - } else { - // 光标位置更新 + if (e.pointerType === "touch") { + const timer = setTimeout(() => { updateCursorPosition(x); - } - } else { - // 悬停时更新光标位置 - updateCursorPosition(x); + }, 500); + setLongPressTimer(timer); } }, - [isDragging, dragStartX, viewBox, updateCursorPosition], + [updateCursorPosition, setLongPressTimer, setDragStartX, setPointerStartPosition], ); - const handleMouseUp = useCallback(() => { - console.log("mouse up"); - setIsDragging(false); - }, []); + // Function to start Y-axis animation + const startYAxisAnimation = useCallback( + (targetMin: number, targetMax: number) => { + if (yAxisAnimationRef.current) { + cancelAnimationFrame(yAxisAnimationRef.current); + } - const handleMouseLeave = useCallback(() => { - console.log("mouse leave"); - setIsDragging(false); - setCurrentPosition(null); - }, []); + const animationDuration = 300; // 300ms animation + const startTimestamp = performance.now(); - // 触摸事件处理 - const handleTouchStart = useCallback( - (e: React.TouchEvent) => { - console.log("touch start"); + const currentMin = yAxisRange.min; + const currentMax = yAxisRange.max; + + setYAxisAnimation({ + isAnimating: true, + startMin: currentMin, + startMax: currentMax, + targetMin, + targetMax, + startTimestamp, + }); + + const animate = (currentTime: number) => { + const elapsed = currentTime - startTimestamp; + const progress = Math.min(elapsed / animationDuration, 1); + + // Easing function for smooth animation (ease-out) + const easeProgress = 1 - Math.pow(1 - progress, 3); + + const newMin = currentMin + (targetMin - currentMin) * easeProgress; + const newMax = currentMax + (targetMax - currentMax) * easeProgress; + + setYAxisRange({ + min: newMin, + max: newMax, + }); + + if (progress < 1) { + yAxisAnimationRef.current = requestAnimationFrame(animate); + } else { + // Animation complete + setYAxisRange({ + min: targetMin, + max: targetMax, + }); + setYAxisAnimation(null); + yAxisAnimationRef.current = null; + } + }; + + yAxisAnimationRef.current = requestAnimationFrame(animate); + }, + [yAxisRange.max, yAxisRange.min], + ); + + const alignRangeToNiceNumbers = (min: number, max: number) => { + const range = max - min; + const magnitude = Math.pow(10, Math.floor(Math.log10(range))); + let alignedMax = Math.ceil(max / magnitude) * magnitude; + let alignedMin = Math.floor(min / magnitude) * magnitude; + + return { min: alignedMin, max: alignedMax }; + }; + + // Lazy adjustment logic for Y-axis range + useEffect(() => { + if (!visibleData.length || yAxisAnimation?.isAnimating) return; + + // Check if we need to adjust Y-axis range (when significant data is outside current range) + const dataOutsideRange = visibleData.filter((d) => d.value < yAxisRange.min || d.value > yAxisRange.max).length; + + const threshold = visibleData.length * 0.1; // Lower threshold: 30% of data outside range + const vmin = visibleData[leftPad - 1].value; + const vmax = visibleData[visibleData.length - rightPad].value; + + if (dataOutsideRange > threshold) { + const targetMin = Math.max(0, vmin); + const targetMax = vmax; + + const buffer = (targetMax - targetMin) * 0.3; + + // Align to nice numbers + const alignedRange = alignRangeToNiceNumbers(targetMin - buffer, targetMax + buffer); + + // Start animation to new range + startYAxisAnimation(alignedRange.min, alignedRange.max); + } + }, [visibleData, yAxisRange, yAxisAnimation]); + + const handlePointerMove = useCallback( + (e: React.PointerEvent) => { if (!svgRef.current) return; - const touch = e.touches[0]; + if (longPressTimer) { + clearTimeout(longPressTimer); + } + + setLastPointerPositions( + [...lastPointerPositions, { x: e.clientX, y: e.clientY, time: Date.now() }].slice(-8), + ); + const rect = svgRef.current.getBoundingClientRect(); - const x = touch.clientX - rect.left; + const x = e.clientX - rect.left; - setIsDragging(true); - setDragStartX(x); - updateCursorPosition(x); - }, - [updateCursorPosition], - ); + if (currentPosition && e.pointerType === "touch") { + updateCursorPosition(x); + return; + } - const handleTouchMove = useCallback( - (e: React.TouchEvent) => { - console.log("touch move"); - if (!isDragging || !svgRef.current) return; - - const touch = e.touches[0]; - const rect = svgRef.current.getBoundingClientRect(); - const x = touch.clientX - rect.left; - - // 滚动逻辑 - const deltaX = x - dragStartX; - if (Math.abs(deltaX) > 10) { - const dragSpeed = 0.02; - const newStart = Math.max(0, viewBox.start - deltaX * dragSpeed); - const newEnd = Math.min(1, viewBox.end - deltaX * dragSpeed); - - if (newEnd - newStart === viewBox.end - viewBox.start) { - setViewBox({ start: newStart, end: newEnd }); - } - setDragStartX(x); - } else { - // 光标位置更新 + if (e.pointerType === "mouse") { updateCursorPosition(x); } + + if (!dragStartX) return; + + const deltaX = x - dragStartX; + + const windowWidth = dimensions.width - yLabelWidth - 5; + const timeRange = timeWindow.endTime - timeWindow.startTime; + + const timeDelta = (deltaX / windowWidth) * timeRange; + const newStartTime = timeWindow.startTime - timeDelta; + const newEndTime = timeWindow.endTime - timeDelta; + + const minTime = sortedData[0].timestamp; + const maxTime = sortedData[sortedData.length - 1].timestamp; + + const adjustedStartTime = Math.max(minTime - timeRange * 0.2, newStartTime); + const adjustedEndTime = Math.min(maxTime + timeRange * 1, newEndTime); + + if (adjustedEndTime - adjustedStartTime < timeRange) { + return; + } + + setTimeWindow({ + startTime: adjustedStartTime, + endTime: adjustedEndTime, + }); + setDragStartX(x); + + if (longPressTimer) { + clearTimeout(longPressTimer); + setLongPressTimer(null); + } }, - [isDragging, dragStartX, viewBox, updateCursorPosition], + [dragStartX, timeWindow, dimensions.width, updateCursorPosition], ); - const handleTouchEnd = useCallback(() => { - console.log("touch end"); - setIsDragging(false); - setCurrentPosition(null); - }, []); + const startAnimation = useCallback( + (targetStartTime: number, targetEndTime: number) => { + if (animationRef.current) { + cancelAnimationFrame(animationRef.current); + } + const animationDuration = 300; // 300ms animation + const startTimestamp = performance.now(); + + const currentStartTime = timeWindow.startTime; + const currentEndTime = timeWindow.endTime; + + const animate = (currentTime: number) => { + const elapsed = currentTime - startTimestamp; + const progress = Math.min(elapsed / animationDuration, 1); + + // Easing function for smooth animation (ease-out) + const easeProgress = 1 - Math.pow(1 - progress, 3); + + const newStartTime = currentStartTime + (targetStartTime - currentStartTime) * easeProgress; + const newEndTime = currentEndTime + (targetEndTime - currentEndTime) * easeProgress; + + setTimeWindow({ + startTime: newStartTime, + endTime: newEndTime, + }); + + if (progress < 1) { + animationRef.current = requestAnimationFrame(animate); + } else { + // Animation complete + setTimeWindow({ + startTime: targetStartTime, + endTime: targetEndTime, + }); + animationRef.current = null; + } + }; + + animationRef.current = requestAnimationFrame(animate); + }, + [timeWindow], + ); + + const getWindowShiftForAnimation = (timeRange: number, delta: number, type: "mouse" | "pen" | "touch") => { + if (type === "mouse") { + return delta > 0 ? -timeRange / 3 : timeRange / 3; + } else return delta > 0 ? -timeRange : timeRange; + }; + + const handlePointerUp = useCallback( + (e: React.PointerEvent) => { + if (!svgRef.current) return; + const rect = svgRef.current.getBoundingClientRect(); + const x = e.clientX - rect.left; + + setDragStartX(0); + + if (longPressTimer) { + updateCursorPosition(x); + } + + if (currentPosition) return; + + // Check for swipe gesture on all devices (not just touch) + if (!lastPointerPositions || !pointerStartPosition) return; + + const totalDeltaX = x - pointerStartPosition.x; + const totalTimeDelta = Date.now() - pointerStartPosition.time; + + const avgAcceleration = calculateXAxisAverageAcceleration(lastPointerPositions); + const signAcc = avgAcceleration / Math.abs(avgAcceleration); + + // Only animate if the average acceleration is in the same direction as the swipe + if ((avgAcceleration - 500 * signAcc) * totalDeltaX > 0) { + const timeRange = timeWindow.endTime - timeWindow.startTime; + const windowShift = getWindowShiftForAnimation(timeRange, totalDeltaX, e.pointerType); + + const newStartTime = Math.max(data[0]?.timestamp || 0, timeWindow.startTime + windowShift); + const newEndTime = Math.min( + data[data.length - 1]?.timestamp || Infinity, + timeWindow.endTime + windowShift, + ); + + if (newEndTime - newStartTime === timeRange) { + // Use smooth animation instead of direct set + startAnimation(newStartTime, newEndTime); + } + } + setPointerStartPosition(null); + setLastPointerPositions([]); + }, + [longPressTimer, timeWindow, data, startAnimation], + ); + + const handlePointerLeave = useCallback((e: React.PointerEvent) => { + if (e.pointerType === "touch") { + return; + } + setCurrentPosition(null); + setDragStartX(0); + setPointerStartPosition(null); + setLastPointerPositions([]); + }, []); if (!data.length) { return ( -
-
- 暂无数据 -
+
+
暂无数据
); } return ( -
- +
- - {/* Y轴刻度 */} - {yTicks && - yTicks.map((tick, index) => ( - - - - {tick.value.toLocaleString()} - - - ))} - - {/* X轴时间刻度 */} - {timeTicks && - timeTicks.map((tick, index) => ( - - - - {tick.label} - - - ))} - - {/* 折线路径 */} - - - {/* 当前光标指示线 */} - {currentPosition && ( - - {/* 垂直指示线 */} - - {/* 数据点 */} - - - )} - - - {/* 浮动数据标签 */} - {currentPosition && ( -
-
-
- {currentPosition.data && - new Date(currentPosition.data.timestamp).toLocaleTimeString([], { - month: "2-digit", - day: "2-digit", - hour: "2-digit", - minute: "2-digit", - })} -
+ + + + + + + + + {/* Y轴刻度 */} + {yTicks && + yTicks.map((tick, index) => ( + + + + {Math.round(tick.value / 1000) / 10} 万 + + + ))} -
- {currentPosition.data && currentPosition.data.value.toLocaleString()} -
-
-
- )} + + {Math.round(globalMaxValue / 1000) / 10} 万 + + + {/* X轴时间刻度 */} + {timeTicks && + timeTicks.map((tick, index) => ( + + + + {tick.label} + + + ))} + + {/* The curve line */} + + + {/* 当前光标指示线 */} + {currentPosition && ( + + {/* 垂直指示线 */} + + {/* 数据点 */} + + + )} + +
); }; diff --git a/packages/temp_frontend/app/components/ui/tabs.tsx b/packages/temp_frontend/app/components/ui/tabs.tsx new file mode 100644 index 0000000..ca800be --- /dev/null +++ b/packages/temp_frontend/app/components/ui/tabs.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import * as TabsPrimitive from "@radix-ui/react-tabs"; + +import { cn } from "@/lib/utils"; + +function Tabs({ className, ...props }: React.ComponentProps) { + return ; +} + +function TabsList({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function TabsTrigger({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +function TabsContent({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Tabs, TabsList, TabsTrigger, TabsContent }; diff --git a/packages/temp_frontend/app/routes.ts b/packages/temp_frontend/app/routes.ts index 89eb556..eea526c 100644 --- a/packages/temp_frontend/app/routes.ts +++ b/packages/temp_frontend/app/routes.ts @@ -3,6 +3,5 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes"; export default [ index("routes/home.tsx"), route("song/:id/info", "routes/song/[id]/info.tsx"), - route("song/:id/data", "routes/song/[id]/data.tsx"), route("chart-demo", "routes/chartDemo.tsx"), ] satisfies RouteConfig; diff --git a/packages/temp_frontend/app/routes/chartDemo.tsx b/packages/temp_frontend/app/routes/chartDemo.tsx index f7efe33..a285a1e 100644 --- a/packages/temp_frontend/app/routes/chartDemo.tsx +++ b/packages/temp_frontend/app/routes/chartDemo.tsx @@ -1,11 +1,19 @@ -import { TimeSeriesChart } from "@/components/Chart"; -import { useEffect, useState } from "react"; +import { TimeSeriesChart, type TimeRange } from "@/components/Chart"; +import React, { useEffect, useState } from "react"; import useSWR from "swr"; +import useDarkTheme from "@alikia/dark-theme-hook"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; const API_URL = "https://api.projectcvsa.com"; const App = () => { - const { data, error, isLoading } = useSWR(`${API_URL}/video/av285205499/snapshots`, async (url) => { + const isDarkMode = useDarkTheme(); + const [range, setRange] = useState("7d"); + const [currentData, setCurrentData] = useState(""); + const [currentDate, setCurrentDate] = useState(""); + const [outside, setOutside] = useState(false); + const ref = React.useRef(null); + const { data, error, isLoading } = useSWR(`${API_URL}/video/av285205499/snapshots?ps=1300`, async (url) => { const response = await fetch(url); if (!response.ok) { throw new Error("Failed to fetch song info"); @@ -18,8 +26,8 @@ const App = () => { const d = []; for (let i = data.length - 1; i >= 0; i--) { d.push({ - timestamp: data[i].created_at, - value: data[i].views + timestamp: new Date(data[i].created_at).getTime(), + value: data[i].views, }); } return d; @@ -33,25 +41,50 @@ const App = () => { } }, [data]); - return (
{ + const targetElement = e.target; + if (ref.current && ref.current.contains(targetElement as Node)) return; + if (e.pointerType !== "touch") return; + setOutside(true); + setTimeout(() => { + setOutside(false); + }, 100); }} > -

健康数据趋势

- - +
+
+ {currentData} + + {currentDate} + +
+ +
+ setRange(v as TimeRange)} className="w-full mt-4"> + + 6小时 + 1天 + 1周 + 30天 + 3个月 + 1年 + 全部 + +
); }; diff --git a/packages/temp_frontend/components.json b/packages/temp_frontend/components.json index 0ed3d90..41b3854 100644 --- a/packages/temp_frontend/components.json +++ b/packages/temp_frontend/components.json @@ -12,11 +12,11 @@ }, "iconLibrary": "lucide", "aliases": { - "components": "~/components", - "utils": "~/lib/utils", - "ui": "~/components/ui", - "lib": "~/lib", - "hooks": "~/hooks" + "components": "@/components/", + "utils": "@/lib/utils", + "ui": "@/components/ui/", + "lib": "@/lib/", + "hooks": "@/components/hooks/" }, "registries": {} } diff --git a/packages/temp_frontend/package.json b/packages/temp_frontend/package.json index 248b8d7..ce9e9d0 100644 --- a/packages/temp_frontend/package.json +++ b/packages/temp_frontend/package.json @@ -9,10 +9,12 @@ "format": "prettier --write ." }, "dependencies": { + "@alikia/dark-theme-hook": "^1.0.2", "@elysiajs/eden": "^1.4.1", "@nivo/core": "^0.99.0", "@nivo/line": "^0.99.0", "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-tabs": "^1.1.13", "@react-router/node": "^7.7.1", "@react-router/serve": "^7.7.1", "class-variance-authority": "^0.7.1", diff --git a/packages/temp_frontend/tsconfig.json b/packages/temp_frontend/tsconfig.json index 1c636db..1ee5bb3 100644 --- a/packages/temp_frontend/tsconfig.json +++ b/packages/temp_frontend/tsconfig.json @@ -11,7 +11,8 @@ "baseUrl": ".", "paths": { "@/*": ["./app/*"], - "@elysia/*": ["../elysia/*"] + "@elysia/*": ["../elysia/*"], + "@core/*": ["../core/*"] }, "esModuleInterop": true, "verbatimModuleSyntax": true,