Skip to content

Commit a1325d0

Browse files
committed
Add HiDPI scaling support for charts
1 parent 00253c4 commit a1325d0

6 files changed

Lines changed: 275 additions & 66 deletions

File tree

src/App.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { ThemeToggle } from './components/ThemeToggle';
1010
import { Header } from './components/Header';
1111
import { PanelLeftClose, PanelLeftOpen } from 'lucide-react';
1212
import { mergeFilesWithReplacement } from './utils/mergeFiles.js';
13+
import { useDeviceScale } from './utils/useDeviceScale.js';
1314

1415
// Default global parsing configuration
1516
export const DEFAULT_GLOBAL_PARSING_CONFIG = {
@@ -33,6 +34,7 @@ export const DEFAULT_GLOBAL_PARSING_CONFIG = {
3334

3435
function App() {
3536
const { t } = useTranslation();
37+
useDeviceScale();
3638
const [uploadedFiles, setUploadedFiles] = useState(() => {
3739
const stored = localStorage.getItem('uploadedFiles');
3840
return stored ? JSON.parse(stored) : [];

src/components/ResizablePanel.jsx

Lines changed: 85 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,123 @@
1-
import React, { useState, useRef, useCallback, useEffect } from 'react';
1+
import React, { useState, useRef, useCallback, useEffect, useMemo } from 'react';
22
import { useTranslation } from 'react-i18next';
3+
import { useRootFontSize } from '../utils/useRootFontSize.js';
34

4-
export function ResizablePanel({ children, title, initialHeight = 440, minHeight = 200, maxHeight = 800, actions = null }) {
5-
const [height, setHeight] = useState(initialHeight);
6-
const { t } = useTranslation();
5+
const HEADER_OFFSET_PX = 60;
6+
7+
export function ResizablePanel({
8+
children,
9+
title,
10+
initialHeight = 440,
11+
minHeight = 200,
12+
maxHeight = 800,
13+
actions = null
14+
}) {
15+
const { t } = useTranslation();
16+
const rootFontSize = useRootFontSize();
17+
const pxPerRem = rootFontSize || 16;
18+
19+
const pxToRem = useCallback((px) => px / pxPerRem, [pxPerRem]);
20+
const minHeightRem = useMemo(() => pxToRem(minHeight), [minHeight, pxToRem]);
21+
const maxHeightRem = useMemo(() => pxToRem(maxHeight), [maxHeight, pxToRem]);
22+
const headerOffsetRem = useMemo(() => pxToRem(HEADER_OFFSET_PX), [pxToRem]);
23+
24+
const [heightRem, setHeightRem] = useState(() => pxToRem(initialHeight));
725
const [isResizing, setIsResizing] = useState(false);
826
const panelRef = useRef(null);
927
const startY = useRef(0);
10-
const startHeight = useRef(0);
28+
const startHeightRem = useRef(0);
1129

12-
const handleMouseMove = useCallback((e) => {
30+
const handleMouseMove = useCallback((event) => {
1331
if (!isResizing) return;
14-
15-
const deltaY = e.clientY - startY.current;
16-
const newHeight = Math.min(Math.max(startHeight.current + deltaY, minHeight), maxHeight);
17-
setHeight(newHeight);
18-
}, [isResizing, minHeight, maxHeight]);
32+
33+
const deltaRem = (event.clientY - startY.current) / pxPerRem;
34+
const nextHeightRem = Math.min(
35+
Math.max(startHeightRem.current + deltaRem, minHeightRem),
36+
maxHeightRem
37+
);
38+
setHeightRem(nextHeightRem);
39+
}, [isResizing, maxHeightRem, minHeightRem, pxPerRem]);
1940

2041
const handleMouseUp = useCallback(() => {
2142
setIsResizing(false);
22-
document.removeEventListener('mousemove', handleMouseMove);
23-
document.removeEventListener('mouseup', handleMouseUp);
24-
}, [handleMouseMove]);
43+
}, []);
2544

2645
useEffect(() => {
27-
if (isResizing) {
28-
document.addEventListener('mousemove', handleMouseMove);
29-
document.addEventListener('mouseup', handleMouseUp);
30-
}
31-
46+
if (!isResizing) return undefined;
47+
48+
const stopResize = () => handleMouseUp();
49+
document.addEventListener('mousemove', handleMouseMove);
50+
document.addEventListener('mouseup', stopResize);
51+
3252
return () => {
3353
document.removeEventListener('mousemove', handleMouseMove);
34-
document.removeEventListener('mouseup', handleMouseUp);
54+
document.removeEventListener('mouseup', stopResize);
3555
};
36-
}, [isResizing, handleMouseMove, handleMouseUp]);
56+
}, [handleMouseMove, handleMouseUp, isResizing]);
3757

38-
const handleMouseDown = useCallback((e) => {
58+
const handleMouseDown = useCallback((event) => {
3959
setIsResizing(true);
40-
startY.current = e.clientY;
41-
startHeight.current = height;
42-
e.preventDefault();
43-
e.stopPropagation();
44-
}, [height]);
60+
startY.current = event.clientY;
61+
startHeightRem.current = heightRem;
62+
event.preventDefault();
63+
event.stopPropagation();
64+
}, [heightRem]);
65+
66+
const handleKeyboardResize = useCallback((event) => {
67+
if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') {
68+
return;
69+
}
70+
71+
event.preventDefault();
72+
const deltaRem = (event.key === 'ArrowUp' ? -10 : 10) / pxPerRem;
73+
const nextHeightRem = Math.min(
74+
Math.max(heightRem + deltaRem, minHeightRem),
75+
maxHeightRem
76+
);
77+
setHeightRem(nextHeightRem);
78+
}, [heightRem, maxHeightRem, minHeightRem, pxPerRem]);
79+
80+
const panelTitleId = `panel-title-${title.replace(/\s+/g, '-').toLowerCase()}`;
81+
const contentHeightRem = Math.max(heightRem - headerOffsetRem, minHeightRem - headerOffsetRem);
4582

4683
return (
47-
<section
84+
<section
4885
ref={panelRef}
4986
className="chart-panel p-3"
50-
style={{ height: `${height}px` }}
51-
aria-labelledby={`panel-title-${title.replace(/\s+/g, '-').toLowerCase()}`}
87+
style={{ height: `${heightRem}rem` }}
88+
aria-labelledby={panelTitleId}
5289
>
53-
<div className="flex items-center justify-between mb-2">
90+
<div className="mb-2 flex items-center justify-between">
5491
<h3
55-
id={`panel-title-${title.replace(/\s+/g, '-').toLowerCase()}`}
92+
id={panelTitleId}
5693
className="text-base font-semibold text-gray-800 dark:text-gray-100"
5794
>
58-
📊 {title}
59-
</h3>
95+
📊 {title}
96+
</h3>
6097
{actions && (
6198
<div className="flex gap-2" aria-label={t('chart.actions')}>
6299
{actions}
63100
</div>
64101
)}
65102
</div>
66-
67-
<div
68-
className="chart-container"
69-
style={{ height: `${height - 60}px` }}
103+
104+
<div
105+
className="chart-container"
106+
style={{ height: `${contentHeightRem}rem` }}
70107
role="img"
71-
aria-label={`${title} ${t('chart')}`}
72-
>
73-
{children}
74-
</div>
75-
108+
aria-label={`${title} ${t('chart')}`}
109+
>
110+
{children}
111+
</div>
112+
76113
<button
77114
className="resize-handle"
78-
onMouseDown={handleMouseDown}
79-
title={t('resize.drag')}
80-
aria-label={t('resize.adjust', { title })}
81-
onKeyDown={(e) => {
82-
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
83-
e.preventDefault();
84-
const delta = e.key === 'ArrowUp' ? -10 : 10;
85-
const newHeight = Math.min(Math.max(height + delta, minHeight), maxHeight);
86-
setHeight(newHeight);
87-
}
88-
}}
115+
onMouseDown={handleMouseDown}
116+
onKeyDown={handleKeyboardResize}
117+
title={t('resize.drag')}
118+
aria-label={t('resize.adjust', { title })}
89119
tabIndex={0}
120+
type="button"
90121
/>
91122
</section>
92123
);

src/components/__tests__/ResizablePanel.test.jsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
22
import userEvent from '@testing-library/user-event';
3-
import { describe, it, expect } from 'vitest';
3+
import { afterEach, describe, it, expect } from 'vitest';
44
import { ResizablePanel } from '../ResizablePanel';
55
import i18n from '../../i18n';
66

7+
const getRootFontSize = () => parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
8+
const pxToRem = (px) => `${px / getRootFontSize()}rem`;
9+
10+
afterEach(() => {
11+
cleanup();
12+
});
13+
714
describe('ResizablePanel', () => {
815
it('renders content and adjusts height with keyboard', async () => {
916
const user = userEvent.setup();
@@ -14,17 +21,15 @@ describe('ResizablePanel', () => {
1421
);
1522

1623
const region = screen.getByRole('region', { name: /Test/ });
17-
expect(region.style.height).toBe('300px');
24+
expect(region.style.height).toBe(pxToRem(300));
1825
screen.getByText('content');
1926

2027
const handle = screen.getByRole('button', { name: i18n.t('resize.adjust', { title: 'Test' }) });
2128
handle.focus();
2229
await user.keyboard('{ArrowUp}');
23-
expect(region.style.height).toBe('290px');
30+
expect(region.style.height).toBe(pxToRem(290));
2431
await user.keyboard('{ArrowDown}{ArrowDown}');
25-
expect(region.style.height).toBe('310px');
26-
27-
cleanup();
32+
expect(region.style.height).toBe(pxToRem(310));
2833
});
2934

3035
it('resizes using mouse drag', () => {
@@ -41,8 +46,6 @@ describe('ResizablePanel', () => {
4146
fireEvent.mouseMove(document, { clientY: 40 });
4247
fireEvent.mouseUp(document);
4348

44-
expect(region.style.height).toBe('340px');
45-
46-
cleanup();
49+
expect(region.style.height).toBe(pxToRem(340));
4750
});
4851
});

src/index.css

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,65 @@
22
@tailwind components;
33
@tailwind utilities;
44

5+
/* High DPI scaling ------------------------------------------------------ */
6+
:root {
7+
--ui-scale: 1;
8+
}
9+
10+
html {
11+
font-size: calc(16px * var(--ui-scale));
12+
}
13+
14+
:root.dpr-125 {
15+
--ui-scale: 0.95;
16+
}
17+
18+
:root.dpr-150 {
19+
--ui-scale: 0.925;
20+
}
21+
22+
:root.dpr-175 {
23+
--ui-scale: 0.9;
24+
}
25+
26+
:root.dpr-200 {
27+
--ui-scale: 0.85;
28+
}
29+
30+
:root.dpr-250 {
31+
--ui-scale: 0.8;
32+
}
33+
34+
@media (min-resolution: 1.25dppx) {
35+
:root {
36+
--ui-scale: 0.95;
37+
}
38+
}
39+
40+
@media (min-resolution: 1.5dppx) {
41+
:root {
42+
--ui-scale: 0.925;
43+
}
44+
}
45+
46+
@media (min-resolution: 1.75dppx) {
47+
:root {
48+
--ui-scale: 0.9;
49+
}
50+
}
51+
52+
@media (min-resolution: 2dppx) {
53+
:root {
54+
--ui-scale: 0.85;
55+
}
56+
}
57+
58+
@media (min-resolution: 2.5dppx) {
59+
:root {
60+
--ui-scale: 0.8;
61+
}
62+
}
63+
564
/* Accessibility improvements */
665
@layer base {
766
body {
@@ -106,16 +165,16 @@
106165
/* Custom styles */
107166
.chart-container {
108167
position: relative;
109-
height: 440px;
168+
height: 27.5rem;
110169
width: 100%;
111170
}
112171

113172
.resize-handle {
114173
position: absolute;
115174
bottom: 0;
116175
right: 0;
117-
width: 24px;
118-
height: 24px;
176+
width: 1.5rem;
177+
height: 1.5rem;
119178
cursor: nw-resize;
120179
background: linear-gradient(-45deg, transparent 35%, #9ca3af 35%, #9ca3af 45%, transparent 45%, transparent 55%, #9ca3af 55%, #9ca3af 65%, transparent 65%);
121180
opacity: 0.6;

0 commit comments

Comments
 (0)