Skip to content

Commit 0ed3bc7

Browse files
committed
fix: resolve squished canvas layout in sensor comparison and refine popular models
- Added `flexShrink: 0` to SensorSize canvas to prevent flexbox from compressing the rendering vertically. - Allowed the canvas to grow dynamically to its full content height, utilizing overflow-y: auto on the container. - Removed unused vars and missing hooks dependencies from SensorSize. - Updated SENSORS data to remove Leica models from 24MP entries and Nikon Z8/Canon from specific Full Frame entries to declutter Pixel Density mode labels.
1 parent 4467ef5 commit 0ed3bc7

4 files changed

Lines changed: 82 additions & 95 deletions

File tree

GEMINI.md

Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -5,54 +5,60 @@ PhotoTools is a high-performance, educational photography application providing
55
## Core Design Philosophy
66

77
- **No Page Scroll**: This is a **hard constraint**. The application MUST fit within 100vh. The page never scrolls; only individual panels (controls, results, sidebars) scroll internally via `overflow-y: auto`.
8-
- **Vanilla CSS Only**: No external UI libraries (MUI, Shadcn, etc.) or Tailwind CSS. Use CSS Modules and existing design tokens.
9-
- **Reference Implementation**: The FOV Simulator (`components/tools/fov-simulator/`) is the gold-standard. All new tools must mirror its layout, spacing, and interactive patterns.
8+
- **Vanilla CSS Only**: No external UI libraries (MUI, Shadcn, etc.) or Tailwind CSS. Use CSS Modules and existing design tokens (CSS custom properties).
9+
- **Reference Implementation**: The FOV Simulator (`src/app/fov-simulator/`) is the gold-standard. All new tools must mirror its layout, spacing, and interactive patterns.
1010
- **SEO & Ads**: Layouts must be clean, semantic, and have predictable content regions for ad placement without layout shifts (CLS).
1111

1212
## Architecture & Core Systems
1313

14-
### 1. Tool Registry (`lib/data/tools.ts`)
14+
### 1. Tool Registry (`src/lib/data/tools.ts`)
1515
Central source of truth for all tools.
16-
- **Status**: `live` (visible in production) or `draft` (hidden in production, accessible via direct URL with `DraftBanner`).
17-
- **Visibility**: In `development`, all tools are visible regardless of status.
16+
- **Status**: Each tool has `dev` and `prod` status fields: `'live'`, `'draft'`, or `'disabled'`.
17+
- **Visibility**: `live` tools are fully visible. `draft` tools are hidden from menus but accessible via direct URL with a `DraftBanner`. `disabled` tools are completely hidden.
1818

19-
### 2. Pure Math Modules (`lib/math/`)
20-
Core photography logic is isolated into pure TypeScript modules.
19+
### 2. Pure Math Modules (`src/lib/math/`)
20+
Core photography logic is isolated into pure TypeScript modules (FOV, DOF, exposure, shaders, etc.).
2121
- **TDD Requirement**: Every math module MUST have a co-located `*.test.ts` file with 100% coverage.
2222
- **UI Integration**: Components should be thin wrappers around these math functions.
2323

24-
### 3. Education System (`lib/data/education/`)
24+
### 3. Education System (`src/lib/data/education/`)
2525
Every tool features a `LearnPanel` (right sidebar) defined in `content.ts` and `content2.ts`:
2626
- **Beginner/Deeper Explanations**: Multi-level educational content.
27-
- **Key Factors & Pro Tips**: Bulleted lists and practical advice.
27+
- **Key Factors & Pro Tips**: Bulleted lists and practical advice (amber callouts).
2828
- **Interactive Challenges**: 3-5 multiple-choice questions with persistence to `localStorage`.
2929
- **Tooltips**: Use `InfoTooltip` on control labels for quick definitions.
3030

31-
### 4. Layout Shell (`components/shared/ToolPageShell.tsx`)
31+
### 4. Layout Shell (`src/components/shared/ToolPageShell.tsx`)
3232
Standard three-column layout (Controls | Main Visualizer | LearnPanel). Provides consistent header actions (Share, Export, Info).
3333

3434
## Engineering Standards
3535

36-
- **Tech Stack**: Next.js 16, React 19, TypeScript 6, Vitest, CSS Modules, Canvas/WebGL2.
37-
- **Components**: Use **named exports** and the `'use client'` directive for interactive elements.
38-
- **Testing**: Co-locate test files next to source files (`*.test.ts` or `*.test.tsx`).
39-
- **Build & Run**:
40-
- `npm run dev`: Start dev server with Turbopack.
41-
- `npm run build`: Production build.
42-
- `npm test`: Run all unit and integration tests (170+ tests).
43-
- `npm run lint`: Run ESLint.
44-
45-
## Directory Mapping
46-
47-
- `app/`: Routes (Home, Tools, Glossary).
48-
- `components/layout/`: Global Nav (mega-menu), Footer, Theme providers.
49-
- `components/shared/`: Reusable tool UI components (Shell, LearnPanel, Tooltips).
50-
- `components/tools/`: Tool-specific implementations.
51-
- `lib/math/`: Pure calculation logic + tests.
52-
- `lib/data/`: Static data (Sensors, Focal Lengths, Tool Registry, Education).
36+
- **Tech Stack**: Next.js 16 (App Router, Turbopack), React 19, TypeScript 6, Vitest, CSS Modules, Canvas/WebGL2/GLSL.
37+
- **Code Quality**:
38+
- **200-line file limit**: Keep all `.ts`/`.tsx` files under 200 lines. Break into smaller modules if exceeded.
39+
- **Named exports**: Use for all components.
40+
- **Shared components first**: Check `src/components/shared/` before building tool-specific UI.
41+
- **Testing**: Co-locate test files next to source files (`*.test.ts` or `*.test.tsx`). The project has ~235 tests across 18 files.
42+
- **Privacy Sandbox**: Deprecated. Do not discuss, recommend, or implement any Privacy Sandbox APIs.
43+
44+
## Directory Mapping (all code in `src/`)
45+
46+
- `src/app/`: Routes (Home at `/`, Tools at `/[slug]`, Glossary at `/learn/glossary`).
47+
- `src/app/[slug]/_components/`: Tool-specific UI components (co-located).
48+
- `src/components/layout/`: Global Nav (mega-menu), Footer, Theme providers.
49+
- `src/components/shared/`: Reusable tool UI components (Shell, LearnPanel, Tooltips, etc.).
50+
- `src/lib/math/`: Pure calculation logic + tests.
51+
- `src/lib/data/`: Static data (Sensors, Focal Lengths, Tool Registry, Education, Glossary).
5352
- `public/`: Images, icons, manifest, sitemap, robots.txt.
5453

54+
## Build & Run Commands
55+
56+
- `npm run dev`: Start dev server with Turbopack.
57+
- `npm run build`: Production build.
58+
- `npm test`: Run all Vitest tests.
59+
- `npm run lint`: Run ESLint.
60+
5561
## Deployment & CI
5662

5763
- **GitHub Actions**: Runs `npm ci``npm audit``npm run lint``npm test``npm run build`.
58-
- **Vercel**: Auto-deploys from the `main` branch to `phototools.io`.
64+
- **Vercel**: Auto-deploys from the `main` branch to `www.phototools.io`. The apex `phototools.io` redirects to `www`.

next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3-
import "./.next/dev/types/routes.d.ts";
3+
import "./.next/types/routes.d.ts";
44

55
// NOTE: This file should not be edited
66
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

src/app/sensor-size-comparison/_components/SensorSize.tsx

Lines changed: 44 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
'use client'
22

3-
import { useState, useRef, useEffect, useCallback, type ReactNode } from 'react'
3+
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
44
import { LearnPanel } from '@/components/shared/LearnPanel'
55
import { ModeToggle } from '@/components/shared/ModeToggle'
66
import { ToolActions } from '@/components/shared/ToolActions'
7-
import { getToolBySlug } from '@/lib/data/tools'
87
import ss from './SensorSize.module.css'
98
import { pixelPitch } from '@/lib/math/diffraction'
109
// strParam and intParam kept for reference but query sync is manual
@@ -64,8 +63,6 @@ function roundRect(
6463
ctx.closePath()
6564
}
6665

67-
const tool = getToolBySlug('sensor-size-comparison')
68-
6966
function CustomSensorForm({ onAdd }: { onAdd: (name: string, w: number, h: number, mp: number) => void }) {
7067
const [name, setName] = useState('')
7168
const [w, setW] = useState('')
@@ -148,16 +145,14 @@ function EditSensorRow({ sensor, onSave, onCancel }: {
148145
}
149146

150147
function ControlsPanel({
151-
visible, mode, resolution, customSensors,
152-
onToggleSensor, onModeChange, onResolutionChange, onAddCustom, onRemoveCustom, onRemoveAllCustom, onEditCustom,
148+
visible, mode, customSensors,
149+
onToggleSensor, onModeChange, onAddCustom, onRemoveCustom, onRemoveAllCustom, onEditCustom,
153150
}: {
154151
visible: Set<string>
155152
mode: DisplayMode
156-
resolution: number
157153
customSensors: Required<SensorPreset>[]
158154
onToggleSensor: (id: string) => void
159155
onModeChange: (m: DisplayMode) => void
160-
onResolutionChange: (v: number) => void
161156
onAddCustom: (name: string, w: number, h: number, mp: number) => void
162157
onRemoveCustom: (id: string) => void
163158
onRemoveAllCustom: () => void
@@ -442,8 +437,8 @@ export function SensorSize() {
442437
window.history.replaceState(null, '', url.toString())
443438
}, [visible, mode, resolution, customSensors, hydrated])
444439

445-
const allSensors = [...SENSORS as Required<SensorPreset>[], ...customSensors]
446-
const visibleSensors = allSensors.filter((s) => visible.has(s.id))
440+
const allSensors = useMemo(() => [...SENSORS as Required<SensorPreset>[], ...customSensors], [customSensors])
441+
const visibleSensors = useMemo(() => allSensors.filter((s) => visible.has(s.id)), [allSensors, visible])
447442

448443
const toggleSensor = useCallback((id: string) => {
449444
setVisible((prev) => {
@@ -542,7 +537,7 @@ export function SensorSize() {
542537
}
543538

544539
return { sensors, alphaMap }
545-
}, [visible])
540+
}, [visible, allSensors])
546541

547542
const drawFrame = useCallback(() => {
548543
const canvas = canvasRef.current
@@ -552,12 +547,12 @@ export function SensorSize() {
552547

553548
const dpr = window.devicePixelRatio || 1
554549
const cssWidth = canvas.clientWidth
555-
const isMobile = cssWidth < 600
556-
// Pixel-density mode can be very tall — allocate generous height then crop
557-
const naturalHeight = canvas.clientHeight || 420
558-
const needsTallCanvas = isMobile || mode === 'pixel-density'
559-
const maxHeight = needsTallCanvas ? 5000 : naturalHeight
560-
let cssHeight = maxHeight
550+
if (cssWidth === 0) return // not yet measured
551+
552+
// Always allow canvas to be tall enough for its content and push the table down.
553+
// The .main container has overflow: auto to handle the scrolling.
554+
const maxHeight = 5000
555+
const cssHeight = maxHeight
561556
canvas.style.height = `${cssHeight}px`
562557
canvas.width = cssWidth * dpr
563558
canvas.height = cssHeight * dpr
@@ -611,9 +606,9 @@ export function SensorSize() {
611606
}
612607

613608
// Crop canvas to actual content height
614-
if (needsTallCanvas && contentH < cssHeight) {
615-
const finalH = Math.max(contentH + padding, 200)
616-
canvas.style.height = `${finalH}px`
609+
const finalH = Math.max(contentH, 200)
610+
canvas.style.height = `${finalH}px`
611+
if (finalH < maxHeight) {
617612
const imageData = ctx.getImageData(0, 0, canvas.width, Math.ceil(finalH * dpr))
618613
canvas.height = Math.ceil(finalH * dpr)
619614
ctx.putImageData(imageData, 0, 0)
@@ -697,7 +692,7 @@ export function SensorSize() {
697692
<canvas
698693
ref={canvasRef}
699694
className={ss.canvas}
700-
style={{ width: '100%', flex: 1, minHeight: 300 }}
695+
style={{ width: '100%', minHeight: 300, flexShrink: 0 }}
701696
aria-label={`Sensor size comparison in ${mode} mode`}
702697
role="img"
703698
onMouseMove={handleMouseMove}
@@ -789,21 +784,23 @@ function drawOverlay(
789784
const pillH = 18
790785
const labelGap = 4
791786
const sorted = [...sensors].sort((a, b) => b.w * b.h - a.w * a.h)
792-
const labelRowH = isMobile ? sorted.length * (pillH + labelGap) + 8 : 0
793787

794788
const availW = W - pad * 2 - labelColumnW
795-
// On mobile, don't use full H for scaling — use a reasonable max for the sensor area
796-
const mobileMaxSensorH = isMobile ? Math.min(W * 0.8, 300) : 0
797-
const availH = isMobile
798-
? mobileMaxSensorH
799-
: H - pad * 2 - labelRowH
789+
// Desktop: we want a "comfortable" height but grow if needed for labels
790+
const totalLabelH = sorted.length * pillH + (sorted.length - 1) * labelGap
791+
792+
// On desktop, we want sensors to be large but not exceed a reasonable scale
793+
// If we have a lot of height (like 5000), we don't want to use it all for one sensor.
794+
// We'll target around 400px for the sensor area height if possible.
795+
const targetSensorH = 400
796+
const availH = isMobile ? Math.min(W * 0.8, 300) : Math.max(targetSensorH, totalLabelH)
797+
800798
const scale = Math.min(availW / maxW, availH / maxH)
801799
const rectsH = maxH * scale
802800
const cx = pad + labelColumnW + availW / 2
803-
// On mobile, position from top; on desktop, center vertically
804-
const cy = isMobile
805-
? pad + rectsH / 2
806-
: pad + (H - pad * 2 - labelRowH) / 2
801+
802+
// Position from top since we are in a growing canvas
803+
const cy = pad + Math.max(rectsH, totalLabelH) / 2
807804

808805
// Draw all rects first (fills + strokes), store positions for hit-testing
809806
overlayRects = []
@@ -917,9 +914,9 @@ function drawOverlay(
917914

918915
labelY += pillH + labelGap
919916
}
917+
return labelY + pad
920918
} else {
921919
// ── Desktop: labels as a column to the left of rects ──
922-
const totalLabelH = sorted.length * pillH + (sorted.length - 1) * labelGap
923920
let labelY = cy - totalLabelH / 2
924921

925922
const largestRectLeft = cx - sorted[0].w * scale / 2
@@ -969,16 +966,8 @@ function drawOverlay(
969966

970967
labelY += pillH + labelGap
971968
}
969+
return cy + Math.max(rectsH, totalLabelH) / 2 + pad
972970
}
973-
974-
ctx.textBaseline = 'alphabetic'
975-
if (isMobile) {
976-
const largest = sorted[0]
977-
const lh = largest.h * scale
978-
const bottomOfRects = cy + lh / 2
979-
return bottomOfRects + 8 + labelRowH
980-
}
981-
return H
982971
}
983972

984973
function drawSideBySideRow(
@@ -1098,22 +1087,24 @@ function drawSideBySide(
10981087

10991088
const totalGap = (sensors.length - 1) * gap
11001089
const availW = W - pad * 2 - totalGap
1101-
const availH = H - pad * 2 - labelSpace
1090+
// On desktop single row, we want them to be large but fit a reasonable height
1091+
const targetH = 400
1092+
const availH = targetH
11021093

1103-
let lo = 0, hi = availH / maxH
1094+
let lo = 0, hi = 50 // generous max scale
11041095
for (let iter = 0; iter < 30; iter++) {
11051096
const mid = (lo + hi) / 2
11061097
const totalNeeded = sensors.reduce((sum, s, i) => sum + Math.max(s.w * mid, minTextWidths[i]), 0)
1107-
if (totalNeeded <= availW) lo = mid
1098+
if (totalNeeded <= availW && maxH * mid <= availH) lo = mid
11081099
else hi = mid
11091100
}
11101101
const scale = lo
11111102

11121103
const tallestH = maxH * scale
1113-
const groupH = tallestH + labelSpace
1114-
const baseY = (H + groupH) / 2 - labelSpace
1104+
const baseY = pad + tallestH
11151105

11161106
drawSideBySideRow(ctx, W, pad, sensors, scale, baseY, alphaMap)
1107+
return baseY + labelSpace + pad
11171108
} else {
11181109
// Multiple rows: on mobile use 2 per row, otherwise split in half
11191110
const perRow = isMobile ? 2 : Math.ceil(sensors.length / 2)
@@ -1123,11 +1114,9 @@ function drawSideBySide(
11231114
}
11241115

11251116
const rowGap = 20
1126-
const maxModels = Math.max(...sensors.map(s => (POPULAR_MODELS[s.id] ?? []).length), 0)
1127-
const labelSpace = 36 + maxModels * 12
11281117
const maxH = Math.max(...sensors.map(s => s.h))
11291118

1130-
// Compute scale that fits all rows
1119+
// Compute scale that fits row width
11311120
ctx.font = '10px system-ui, sans-serif'
11321121
const minTextWidths = sensors.map(s => {
11331122
const nameLabel = s.name.length > 14 ? s.name.replace(' (', '\n(') : s.name
@@ -1139,26 +1128,24 @@ function drawSideBySide(
11391128
return Math.max(nameW, modelW) + 8
11401129
})
11411130

1142-
const availH = (H - pad * 2 - (rows.length - 1) * rowGap - labelSpace * rows.length) / rows.length
11431131
const maxRowLen = Math.max(...rows.map(r => r.length))
11441132
const totalGap = (maxRowLen - 1) * gap
11451133
const availW = W - pad * 2 - totalGap
11461134

1147-
// Scale must fit the widest row and the available height
1148-
let lo = 0, hi = Math.max(availH / maxH, 1)
1135+
// On multiple rows, we use a fixed target height per row unless width limited
1136+
const targetRowH = 250
1137+
let lo = 0, hi = targetRowH / maxH
11491138
for (let iter = 0; iter < 30; iter++) {
11501139
const midScale = (lo + hi) / 2
11511140
let fits = true
1152-
let sIdx = 0
11531141
for (const row of rows) {
11541142
const rowNeeded = row.reduce((sum, s) => {
11551143
const tw = minTextWidths[sensors.indexOf(s)]
11561144
return sum + Math.max(s.w * midScale, tw)
11571145
}, 0)
11581146
if (rowNeeded > availW) { fits = false; break }
1159-
sIdx += row.length
11601147
}
1161-
if (fits && maxH * midScale <= availH) lo = midScale
1148+
if (fits) lo = midScale
11621149
else hi = midScale
11631150
}
11641151
const scale = lo
@@ -1170,8 +1157,7 @@ function drawSideBySide(
11701157
const rowLabelSpace = 36 + rowMaxModels * 12
11711158
return { sensorH: rowMaxH, labelSpace: rowLabelSpace, total: rowMaxH + rowLabelSpace }
11721159
})
1173-
const totalHeight = rowHeights.reduce((sum, r) => sum + r.total, 0) + (rows.length - 1) * rowGap
1174-
const startY = isMobile ? pad : (H - totalHeight) / 2
1160+
const startY = pad
11751161

11761162
let curY = startY
11771163
for (let ri = 0; ri < rows.length; ri++) {
@@ -1181,11 +1167,8 @@ function drawSideBySide(
11811167
}
11821168

11831169
ctx.textBaseline = 'alphabetic'
1184-
return startY + totalHeight + pad
1170+
return curY - rowGap + pad
11851171
}
1186-
1187-
ctx.textBaseline = 'alphabetic'
1188-
return H
11891172
}
11901173

11911174
function drawPixelDensity(
@@ -1219,12 +1202,10 @@ function drawPixelDensity(
12191202
}
12201203

12211204
const isMobileScreen = W < 600
1222-
const DESKTOP_COLS_PER_ROW = 5
12231205
// Only use the flat 2-column grid on mobile; desktop uses multi-row column layout
12241206
const useGridLayout = isMobileScreen
12251207
const colGap = useGridLayout ? 12 : 24
12261208
const rowGap = 12
1227-
const labelH = 46
12281209

12291210
// Find maximum sensor dimension for scaling across all displayed sensors
12301211
const maxSensorW = Math.max(...sensors.map((s) => s.w))

src/lib/data/sensors.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,14 +74,14 @@ export const COMMON_MP: Record<string, MpEntry[]> = {
7474
ff: [
7575
{ mp: 12, models: 'Sony A7S III' },
7676
{ mp: 20, models: 'Canon R6' },
77-
{ mp: 24, models: 'Sony A7 III / Nikon Z6 III / Canon R6 Mark II / Leica SL2-S / Leica M10' },
77+
{ mp: 24, models: 'Sony A7 III / Nikon Z6 III' },
7878
{ mp: 33, models: 'Sony A7 IV' },
79-
{ mp: 45, models: 'Canon R5 / Nikon Z8 / Nikon Z9' },
79+
{ mp: 45, models: 'Canon R5 / Nikon Z9' },
8080
{ mp: 61, models: 'Sony A7R V / Sigma fp L' },
8181
],
8282
apsc_n: [
8383
{ mp: 20, models: 'Nikon Z50 / Z50II' },
84-
{ mp: 24, models: 'Sony A6400 / Leica CL' },
84+
{ mp: 24, models: 'Sony A6400' },
8585
{ mp: 26, models: 'Sony A6700 / Fujifilm X-H2S' },
8686
{ mp: 40, models: 'Fujifilm X-T5 / X-H2' },
8787
],

0 commit comments

Comments
 (0)