From d1a74602b9a8dad3a0ea646ddd747d7902549c6c Mon Sep 17 00:00:00 2001 From: Mike Shi Date: Tue, 31 Mar 2026 01:05:36 -0700 Subject: [PATCH 1/2] search count high score --- .../src/components/SearchTotalCountChart.tsx | 77 ++++++++++++++++++- 1 file changed, 75 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/SearchTotalCountChart.tsx b/packages/app/src/components/SearchTotalCountChart.tsx index e9653cdae4..4cd5afcd69 100644 --- a/packages/app/src/components/SearchTotalCountChart.tsx +++ b/packages/app/src/components/SearchTotalCountChart.tsx @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { filterColumnMetaByType, JSDataType, @@ -82,6 +82,51 @@ export function useSearchTotalCount( }; } +function isAprilFools(): boolean { + try { + if ( + typeof window !== 'undefined' && + new URLSearchParams(window.location.search).has('aprilFools') + ) { + return true; + } + const now = new Date(); + return now.getMonth() === 3 && now.getDate() === 1; + } catch { + return false; + } +} + +let _sessionHighScore = 0; + +function useHighScore(totalCount: number | undefined) { + const [highScore, setHighScore] = useState(_sessionHighScore); + const [celebrating, setCelebrating] = useState(false); + const prevCountRef = useRef(undefined); + + useEffect(() => { + if (totalCount == null || totalCount <= 0) return; + if (totalCount === prevCountRef.current) return; + prevCountRef.current = totalCount; + + if (totalCount > _sessionHighScore) { + _sessionHighScore = totalCount; + setHighScore(totalCount); + if (_sessionHighScore > 0) { + setCelebrating(true); + } + } + }, [totalCount]); + + useEffect(() => { + if (!celebrating) return; + const t = setTimeout(() => setCelebrating(false), 2000); + return () => clearTimeout(t); + }, [celebrating]); + + return { highScore, celebrating }; +} + export default function SearchTotalCountChart({ config, queryKeyPrefix, @@ -102,12 +147,40 @@ export default function SearchTotalCountChart({ }, ); + const aprilFools = useMemo(() => isAprilFools(), []); + const { highScore, celebrating } = useHighScore( + aprilFools ? totalCount : undefined, + ); + return ( {isLoading ? ( ··· Results ) : totalCount !== null && !isError ? ( - `${totalCount?.toLocaleString()} Results` + <> + {`${totalCount?.toLocaleString()} Results`} + {aprilFools && highScore > 0 && ( + + {celebrating ? '🏆 NEW HIGH SCORE: ' : '🏆 '} + {highScore.toLocaleString()} + + )} + ) : ( '0 Results' )} From 323516dbd74ec0514ab60bcbeab527cdc26a3157 Mon Sep 17 00:00:00 2001 From: Mike Shi Date: Tue, 31 Mar 2026 01:35:36 -0700 Subject: [PATCH 2/2] flappy house --- packages/app/src/components/DBTimeChart.tsx | 60 ++- .../app/src/components/FlappyHouseGame.tsx | 445 ++++++++++++++++++ .../src/components/charts/DisplaySwitcher.tsx | 3 + 3 files changed, 504 insertions(+), 4 deletions(-) create mode 100644 packages/app/src/components/FlappyHouseGame.tsx diff --git a/packages/app/src/components/DBTimeChart.tsx b/packages/app/src/components/DBTimeChart.tsx index d4f81738c7..db75ef1f82 100644 --- a/packages/app/src/components/DBTimeChart.tsx +++ b/packages/app/src/components/DBTimeChart.tsx @@ -1,4 +1,11 @@ -import React, { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import React, { + memo, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import Link from 'next/link'; import { add, differenceInSeconds } from 'date-fns'; import { @@ -15,6 +22,7 @@ import { DisplayType, } from '@hyperdx/common-utils/dist/types'; import { + ActionIcon, Divider, Group, Popover, @@ -23,7 +31,12 @@ import { Text, Tooltip, } from '@mantine/core'; -import { IconChartBar, IconChartLine, IconSearch } from '@tabler/icons-react'; +import { + IconChartBar, + IconChartLine, + IconDeviceGamepad2, + IconSearch, +} from '@tabler/icons-react'; import api from '@/api'; import { @@ -48,6 +61,7 @@ import ChartErrorState, { } from './charts/ChartErrorState'; import DateRangeIndicator from './charts/DateRangeIndicator'; import DisplaySwitcher from './charts/DisplaySwitcher'; +import FlappyHouseGame from './FlappyHouseGame'; import MVOptimizationIndicator from './MaterializedViews/MVOptimizationIndicator'; type ActiveClickPayload = { @@ -453,6 +467,9 @@ function DBTimeChartComponent({ } }, [config.compareToPreviousPeriod]); + const [flappyHouseMode, setFlappyBirdMode] = useState(false); + const chartAreaRef = useRef(null); + const [activeClickPayload, setActiveClickPayload] = useState< ActiveClickPayload | undefined >(undefined); @@ -662,6 +679,29 @@ function DBTimeChartComponent({ disabled: config.compareToPreviousPeriod, }, ]} + suffix={ + displayType === DisplayType.StackedBar ? ( + setFlappyBirdMode(prev => !prev)} + title="Game Mode" + style={{ + opacity: flappyHouseMode ? 0.7 : 0, + transition: 'opacity 0.2s', + }} + onMouseEnter={e => { + (e.currentTarget as HTMLElement).style.opacity = '0.7'; + }} + onMouseLeave={e => { + (e.currentTarget as HTMLElement).style.opacity = + flappyHouseMode ? '0.7' : '0'; + }} + > + + + ) : undefined + } />, ); } @@ -675,6 +715,7 @@ function DBTimeChartComponent({ builderQueriedConfig, config, displayType, + flappyHouseMode, handleSetDisplayType, showDisplaySwitcher, source, @@ -708,7 +749,10 @@ function DBTimeChartComponent({ No data found within time range. ) : ( - <> +
- + {flappyHouseMode && + displayType === DisplayType.StackedBar && + chartAreaRef.current && ( + setFlappyBirdMode(false)} + /> + )} +
)} ); diff --git a/packages/app/src/components/FlappyHouseGame.tsx b/packages/app/src/components/FlappyHouseGame.tsx new file mode 100644 index 0000000000..8f85ed0a00 --- /dev/null +++ b/packages/app/src/components/FlappyHouseGame.tsx @@ -0,0 +1,445 @@ +import { + RefObject, + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, +} from 'react'; + +const HOUSE_RADIUS = 12; +const GRAVITY = 0.4; +const FLAP_VELOCITY = -7; +const HOUSE_SPEED = 2.5; +const OVERLAY_COLOR = 'rgba(0, 0, 0, 0.3)'; +const SCORE_FONT = '24px "IBM Plex Mono", monospace'; +const HIGH_SCORE_FONT = '16px "IBM Plex Mono", monospace'; +const INFO_FONT = '14px "IBM Plex Mono", monospace'; +const HIGH_SCORE_COLOR = '#FFD700'; +const HIGH_SCORE_GLOW = 'rgba(255, 215, 0, 0.6)'; + +let _sessionHighScore = 0; + +const CLICKHOUSE_SVG = ` + + + + + +`; + +function loadClickHouseIcon(): HTMLImageElement { + const img = new Image(); + const blob = new Blob([CLICKHOUSE_SVG], { type: 'image/svg+xml' }); + img.src = URL.createObjectURL(blob); + return img; +} + +interface BarRect { + x: number; + y: number; + width: number; + height: number; +} + +function readBarRects(container: HTMLElement): BarRect[] { + const svg = container.querySelector('svg.recharts-surface'); + if (!svg) return []; + + const rects: BarRect[] = []; + // Recharts bar rectangles are inside elements + const barGroups = svg.querySelectorAll('.recharts-bar-rectangle rect'); + barGroups.forEach(rect => { + const x = parseFloat(rect.getAttribute('x') || '0'); + const y = parseFloat(rect.getAttribute('y') || '0'); + const width = parseFloat(rect.getAttribute('width') || '0'); + const height = parseFloat(rect.getAttribute('height') || '0'); + if (width > 0 && height > 0) { + rects.push({ x, y, width, height }); + } + }); + + return rects; +} + +// Merge overlapping/adjacent bars at the same x position into single obstacles +function mergeBarRects(rects: BarRect[]): BarRect[] { + if (rects.length === 0) return []; + + // Group by x position (bars stacked at same x) + const byX = new Map(); + for (const r of rects) { + const key = Math.round(r.x); + if (!byX.has(key)) byX.set(key, []); + byX.get(key)!.push(r); + } + + const merged: BarRect[] = []; + for (const group of byX.values()) { + // Find bounding box of all rects at this x + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (const r of group) { + minX = Math.min(minX, r.x); + minY = Math.min(minY, r.y); + maxX = Math.max(maxX, r.x + r.width); + maxY = Math.max(maxY, r.y + r.height); + } + merged.push({ + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }); + } + + return merged.sort((a, b) => a.x - b.x); +} + +const ICON_SIZE = HOUSE_RADIUS * 2; + +function drawHouse( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + velocity: number, + icon: HTMLImageElement, +) { + ctx.save(); + ctx.translate(x, y); + + // Tilt based on velocity + const angle = Math.max(-0.5, Math.min(0.5, velocity * 0.05)); + ctx.rotate(angle); + + ctx.drawImage(icon, -ICON_SIZE / 2, -ICON_SIZE / 2, ICON_SIZE, ICON_SIZE); + + ctx.restore(); +} + +interface FlappyHouseGameProps { + containerRef: RefObject; + onClose: () => void; +} + +export default function FlappyHouseGame({ + containerRef, + onClose, +}: FlappyHouseGameProps) { + const canvasRef = useRef(null); + const iconRef = useRef(loadClickHouseIcon()); + const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 }); + + // Game state refs (using refs to avoid re-renders during game loop) + const houseRef = useRef({ x: 50, y: 100, velocity: 0 }); + const scoreRef = useRef(0); + const passedBarsRef = useRef(new Set()); + const gameOverRef = useRef(false); + const startedRef = useRef(false); + const barsRef = useRef([]); + const animFrameRef = useRef(0); + const highScoreRef = useRef(_sessionHighScore); + const newHighScoreTimeRef = useRef(0); // timestamp when new high score was hit + + // Read bar positions from the chart SVG and keep them in sync + const refreshBars = useCallback(() => { + if (!containerRef.current) return; + const raw = readBarRects(containerRef.current); + barsRef.current = mergeBarRects(raw); + }, [containerRef]); + + // Initial read + observe DOM mutations to re-read when chart updates + useLayoutEffect(() => { + refreshBars(); + + if (!containerRef.current) return; + const observer = new MutationObserver(() => { + refreshBars(); + }); + observer.observe(containerRef.current, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ['x', 'y', 'width', 'height', 'd'], + }); + return () => observer.disconnect(); + }, [containerRef, refreshBars]); + + // Set canvas size to match container, and update on resize + useLayoutEffect(() => { + if (!containerRef.current) return; + const updateSize = () => { + if (!containerRef.current) return; + const rect = containerRef.current.getBoundingClientRect(); + setCanvasSize({ width: rect.width, height: rect.height }); + }; + updateSize(); + + const resizeObserver = new ResizeObserver(updateSize); + resizeObserver.observe(containerRef.current); + return () => resizeObserver.disconnect(); + }, [containerRef]); + + const resetGame = useCallback(() => { + houseRef.current = { + x: 50, + y: canvasSize.height / 2, + velocity: 0, + }; + scoreRef.current = 0; + passedBarsRef.current = new Set(); + gameOverRef.current = false; + startedRef.current = false; + }, [canvasSize.height]); + + // Game loop + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || canvasSize.width === 0) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + const chartHeight = canvasSize.height; + const chartWidth = canvasSize.width; + + function gameLoop() { + if (!ctx) return; + + // Read bars from ref each frame so they stay in sync with chart updates + const bars = barsRef.current; + const house = houseRef.current; + + if (startedRef.current && !gameOverRef.current) { + // Physics + house.velocity += GRAVITY; + house.y += house.velocity; + house.x += HOUSE_SPEED; + + // Wrap around + if (house.x > chartWidth + HOUSE_RADIUS) { + house.x = -HOUSE_RADIUS; + passedBarsRef.current.clear(); + } + + // Floor/ceiling collision + if (house.y + HOUSE_RADIUS > chartHeight) { + house.y = chartHeight - HOUSE_RADIUS; + gameOverRef.current = true; + } + if (house.y - HOUSE_RADIUS < 0) { + house.y = HOUSE_RADIUS; + house.velocity = 0; + } + + // Bar collision + for (let i = 0; i < bars.length; i++) { + const bar = bars[i]; + const houseLeft = house.x - HOUSE_RADIUS; + const houseRight = house.x + HOUSE_RADIUS; + const houseTop = house.y - HOUSE_RADIUS * 0.8; + const houseBottom = house.y + HOUSE_RADIUS * 0.8; + + const barRight = bar.x + bar.width; + const barBottom = bar.y + bar.height; + + if ( + houseRight > bar.x && + houseLeft < barRight && + houseBottom > bar.y && + houseTop < barBottom + ) { + gameOverRef.current = true; + break; + } + + // Score: passed a bar + if (house.x > barRight && !passedBarsRef.current.has(i)) { + passedBarsRef.current.add(i); + scoreRef.current++; + + // Check for new high score + if (scoreRef.current > highScoreRef.current) { + highScoreRef.current = scoreRef.current; + _sessionHighScore = scoreRef.current; + newHighScoreTimeRef.current = Date.now(); + } + } + } + } + + // Draw + ctx.clearRect(0, 0, chartWidth, chartHeight); + + // Semi-transparent overlay + ctx.fillStyle = OVERLAY_COLOR; + ctx.fillRect(0, 0, chartWidth, chartHeight); + + // Draw bars as highlights + ctx.fillStyle = 'rgba(80, 250, 123, 0.4)'; + ctx.strokeStyle = 'rgba(80, 250, 123, 0.8)'; + ctx.lineWidth = 1; + for (const bar of bars) { + ctx.fillRect(bar.x, bar.y, bar.width, bar.height); + ctx.strokeRect(bar.x, bar.y, bar.width, bar.height); + } + + // Draw house + drawHouse(ctx, house.x, house.y, house.velocity, iconRef.current); + + // Score + ctx.fillStyle = '#FFF'; + ctx.font = SCORE_FONT; + ctx.textAlign = 'center'; + ctx.fillText(`${scoreRef.current}`, chartWidth / 2, 35); + + // High score display + if (highScoreRef.current > 0) { + const msSinceNewHighScore = Date.now() - newHighScoreTimeRef.current; + const isCelebrating = msSinceNewHighScore < 2000; + + if (isCelebrating) { + // Pulsing gold glow animation + const pulse = Math.sin(msSinceNewHighScore / 150) * 0.3 + 0.7; + ctx.save(); + ctx.shadowColor = HIGH_SCORE_GLOW; + ctx.shadowBlur = 12 * pulse; + ctx.fillStyle = HIGH_SCORE_COLOR; + ctx.font = HIGH_SCORE_FONT; + ctx.fillText( + `NEW HIGH SCORE: ${highScoreRef.current}`, + chartWidth / 2, + 60, + ); + ctx.restore(); + } else { + ctx.fillStyle = 'rgba(255, 215, 0, 0.5)'; + ctx.font = INFO_FONT; + ctx.fillText( + `High Score: ${highScoreRef.current}`, + chartWidth / 2, + 58, + ); + } + } + + // Instructions + if (!startedRef.current) { + ctx.fillStyle = 'rgba(255, 255, 255, 0.9)'; + ctx.font = INFO_FONT; + ctx.fillText('Press SPACE to start flapping!', chartWidth / 2, chartHeight / 2 + 30); + ctx.fillText('Press ESC to exit', chartWidth / 2, chartHeight / 2 + 52); + } + + if (gameOverRef.current) { + const isNewHighScore = + scoreRef.current > 0 && + scoreRef.current >= highScoreRef.current; + const boxHeight = isNewHighScore ? 115 : 90; + + ctx.fillStyle = 'rgba(0, 0, 0, 0.5)'; + ctx.fillRect( + chartWidth / 2 - 120, + chartHeight / 2 - 40, + 240, + boxHeight, + ); + ctx.fillStyle = '#FF6347'; + ctx.font = SCORE_FONT; + ctx.fillText('Game Over!', chartWidth / 2, chartHeight / 2 - 5); + ctx.fillStyle = '#FFF'; + ctx.font = INFO_FONT; + ctx.fillText( + `Score: ${scoreRef.current}`, + chartWidth / 2, + chartHeight / 2 + 20, + ); + + if (isNewHighScore) { + const pulse = + Math.sin(Date.now() / 150) * 0.3 + 0.7; + ctx.save(); + ctx.shadowColor = HIGH_SCORE_GLOW; + ctx.shadowBlur = 10 * pulse; + ctx.fillStyle = HIGH_SCORE_COLOR; + ctx.fillText( + 'NEW HIGH SCORE!', + chartWidth / 2, + chartHeight / 2 + 40, + ); + ctx.restore(); + ctx.fillStyle = '#FFF'; + ctx.fillText( + 'SPACE to retry • ESC to exit', + chartWidth / 2, + chartHeight / 2 + 62, + ); + } else { + ctx.fillText( + 'SPACE to retry • ESC to exit', + chartWidth / 2, + chartHeight / 2 + 40, + ); + } + } + + animFrameRef.current = requestAnimationFrame(gameLoop); + } + + animFrameRef.current = requestAnimationFrame(gameLoop); + + return () => { + cancelAnimationFrame(animFrameRef.current); + }; + }, [canvasSize]); + + // Keyboard handler + useEffect(() => { + function handleKeyDown(e: KeyboardEvent) { + if (e.code === 'Space') { + e.preventDefault(); + if (gameOverRef.current) { + resetGame(); + startedRef.current = true; + houseRef.current.velocity = FLAP_VELOCITY; + } else if (!startedRef.current) { + startedRef.current = true; + houseRef.current.velocity = FLAP_VELOCITY; + } else { + houseRef.current.velocity = FLAP_VELOCITY; + } + } + if (e.code === 'Escape') { + onClose(); + } + } + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onClose, resetGame]); + + // Initialize house position + useEffect(() => { + if (canvasSize.height > 0) { + houseRef.current.y = canvasSize.height / 2; + } + }, [canvasSize.height]); + + return ( + + ); +} diff --git a/packages/app/src/components/charts/DisplaySwitcher.tsx b/packages/app/src/components/charts/DisplaySwitcher.tsx index 7b59e58267..d84621eff3 100644 --- a/packages/app/src/components/charts/DisplaySwitcher.tsx +++ b/packages/app/src/components/charts/DisplaySwitcher.tsx @@ -10,12 +10,14 @@ interface DisplaySwitcherProps { icon: React.ReactNode; disabled?: boolean; }[]; + suffix?: React.ReactNode; } function DisplaySwitcher({ value, onChange, options, + suffix, }: DisplaySwitcherProps) { return ( ({ ))} + {suffix} ); }