@@ -10,205 +10,178 @@ interface StackingDiagramProps {
1010 farLimit : number
1111}
1212
13- const BAND_COLORS = [
14- 'var(--lens-a, #3b82f6)' , // blue
15- 'var(--lens-b, #ef4444)' , // red
16- 'var(--lens-c, #22c55e)' , // green
17- '#f59e0b' , // amber
18- '#8b5cf6' , // purple
19- '#ec4899' , // pink
13+ const COLORS = [
14+ '#3b82f6' , '#ef4444' , '#22c55e' , '#f59e0b' , '#8b5cf6' , '#ec4899' ,
2015]
21-
22- const PADDING_LEFT = 70
23- const PADDING_RIGHT = 24
24- const PADDING_TOP = 32
25- const PADDING_BOTTOM = 40
26- const BAND_HEIGHT = 20
27- const BAND_GAP = 4
28- const SVG_MIN_HEIGHT = 200
29-
30- function distToX ( d : number , logMin : number , logRange : number , drawWidth : number ) : number {
31- return PADDING_LEFT + ( ( Math . log ( d ) - logMin ) / logRange ) * drawWidth
16+ const PAD = { top : 28 , right : 24 , bottom : 40 , left : 56 }
17+ const VB_W = 800
18+ const DRAW_W = VB_W - PAD . left - PAD . right
19+ const BAND_H = 18
20+ const BAND_GAP = 3
21+ const MAX_BAND_SHOTS = 20
22+ const COV_H = 32
23+ const COV_COLS = 200
24+ const FOCUS_H = 28
25+
26+ function distToX ( d : number , logMin : number , logRange : number ) : number {
27+ return PAD . left + ( ( Math . log ( d ) - logMin ) / logRange ) * DRAW_W
3228}
3329
3430function generateTicks ( min : number , max : number ) : number [ ] {
35- const ticks : number [ ] = [ ]
36- const decades = [ 0.1 , 0.2 , 0.5 , 1 , 2 , 5 , 10 , 20 , 50 , 100 ]
37- for ( const d of decades ) {
38- if ( d >= min * 0.9 && d <= max * 1.1 ) ticks . push ( d )
39- }
40- return ticks
31+ return [ 0.05 , 0.1 , 0.2 , 0.5 , 1 , 2 , 5 , 10 , 20 , 50 , 100 ]
32+ . filter ( ( d ) => d >= min * 0.9 && d <= max * 1.1 )
33+ }
34+
35+ function fmtDist ( d : number ) : string {
36+ return d >= 1 ? ` ${ d } m` : ` ${ Math . round ( d * 100 ) } cm`
4137}
4238
4339export function StackingDiagram ( { result, nearLimit, farLimit } : StackingDiagramProps ) {
4440 const t = useTranslations ( 'toolUI.focus-stacking-calculator' )
4541 const { shots } = result
42+ const showBands = shots . length <= MAX_BAND_SHOTS
4643
47- const diagramData = useMemo ( ( ) => {
48- // Determine log scale bounds with some padding
49- const allDistances = shots . flatMap ( ( s ) => [ s . nearFocus , s . farFocus ] )
50- allDistances . push ( nearLimit , farLimit )
51- const minD = Math . max ( 0.05 , Math . min ( ...allDistances ) * 0.8 )
52- const maxD = Math . max ( ...allDistances ) * 1.2
53-
44+ const data = useMemo ( ( ) => {
45+ const allD = shots . flatMap ( ( s ) => [ s . nearFocus , s . farFocus ] ) . concat ( nearLimit , farLimit )
46+ const minD = Math . max ( 0.01 , Math . min ( ...allD ) * 0.8 )
47+ const maxD = Math . max ( ...allD ) * 1.2
5448 const logMin = Math . log ( minD )
55- const logMax = Math . log ( maxD )
56- const logRange = logMax - logMin
57-
58- const bandsHeight = shots . length * ( BAND_HEIGHT + BAND_GAP )
59- const svgHeight = Math . max ( SVG_MIN_HEIGHT , PADDING_TOP + bandsHeight + PADDING_BOTTOM )
60-
49+ const logRange = Math . log ( maxD ) - logMin
6150 const ticks = generateTicks ( minD , maxD )
6251
63- return { logMin, logRange, svgHeight, ticks, minD, maxD }
52+ const cov = new Array ( COV_COLS ) . fill ( 0 ) as number [ ]
53+ let maxCov = 0
54+ for ( let c = 0 ; c < COV_COLS ; c ++ ) {
55+ const d = Math . exp ( logMin + ( c / ( COV_COLS - 1 ) ) * logRange )
56+ for ( const s of shots ) {
57+ if ( d >= s . nearFocus && d <= s . farFocus ) cov [ c ] ++
58+ }
59+ if ( cov [ c ] > maxCov ) maxCov = cov [ c ]
60+ }
61+ return { logMin, logRange, ticks, cov, maxCov }
6462 } , [ shots , nearLimit , farLimit ] )
6563
66- const { logMin, logRange, svgHeight, ticks } = diagramData
64+ const { logMin, logRange, ticks, cov, maxCov } = data
65+
66+ // Layout vertical sections
67+ let y = PAD . top
68+ const covY = y ; y += COV_H + 10
69+ const focusY = y ; y += FOCUS_H
70+ let bandsY = 0
71+ if ( showBands ) { y += 10 ; bandsY = y ; y += shots . length * ( BAND_H + BAND_GAP ) }
72+ const svgH = y + PAD . bottom
73+
74+ const nearX = distToX ( nearLimit , logMin , logRange )
75+ const farX = distToX ( farLimit , logMin , logRange )
76+ const colW = DRAW_W / COV_COLS
6777
68- // We use viewBox for responsiveness; drawWidth is in viewBox units
69- const viewBoxWidth = 800
70- const drawWidth = viewBoxWidth - PADDING_LEFT - PADDING_RIGHT
78+ const dotR = shots . length <= 10 ? 4 : shots . length <= 30 ? 3 : shots . length <= 60 ? 2.5 : 2
79+ const step = shots . length <= 40 ? 1 : shots . length <= 80 ? 2 : Math . ceil ( shots . length / 40 )
7180
72- const nearX = distToX ( nearLimit , logMin , logRange , drawWidth )
73- const farX = distToX ( farLimit , logMin , logRange , drawWidth )
81+ // Deduplicated legend values
82+ const legendVals = [ 1 , Math . ceil ( maxCov / 2 ) , maxCov ] . filter ( ( v , i , a ) => a . indexOf ( v ) === i )
7483
7584 return (
76- < svg
77- viewBox = { `0 0 ${ viewBoxWidth } ${ svgHeight } ` }
78- width = "100%"
79- style = { { maxWidth : 900 , height : 'auto' } }
80- role = "img"
81- aria-label = { t ( 'diagram' ) }
82- >
83- { /* Background */ }
84- < rect x = { 0 } y = { 0 } width = { viewBoxWidth } height = { svgHeight } fill = "var(--bg-surface)" rx = { 8 } / >
85-
86- { /* Near/far limit dashed lines */ }
87- < line
88- x1 = { nearX } y1 = { PADDING_TOP - 12 }
89- x2 = { nearX } y2 = { svgHeight - PADDING_BOTTOM + 8 }
90- stroke = "var(--accent)" strokeWidth = { 1.5 } strokeDasharray = "4 3" opacity = { 0.7 }
91- />
92- < text
93- x = { nearX } y = { PADDING_TOP - 16 }
94- fill = "var(--accent)" fontSize = { 10 } textAnchor = "middle" fontWeight = { 600 }
95- >
96- { t ( 'diagramNear' ) }
97- </ text >
98- < line
99- x1 = { farX } y1 = { PADDING_TOP - 12 }
100- x2 = { farX } y2 = { svgHeight - PADDING_BOTTOM + 8 }
101- stroke = "var(--accent)" strokeWidth = { 1.5 } strokeDasharray = "4 3" opacity = { 0.7 }
102- />
103- < text
104- x = { farX } y = { PADDING_TOP - 16 }
105- fill = "var(--accent)" fontSize = { 10 } textAnchor = "middle" fontWeight = { 600 }
106- >
107- { t ( 'diagramFar' ) }
108- </ text >
109-
110- { /* Camera icon (simple representation) */ }
111- < text
112- x = { PADDING_LEFT - 10 } y = { PADDING_TOP + ( shots . length * ( BAND_HEIGHT + BAND_GAP ) ) / 2 + 4 }
113- fill = "var(--text-secondary)" fontSize = { 20 } textAnchor = "end"
114- >
115- { '\uD83D\uDCF7' }
116- </ text >
117-
118- { /* Shot bands */ }
85+ < svg viewBox = { `0 0 ${ VB_W } ${ svgH } ` } width = "100%"
86+ style = { { maxWidth : 900 , height : 'auto' } } role = "img" aria-label = { t ( 'diagram' ) } >
87+ < rect x = { 0 } y = { 0 } width = { VB_W } height = { svgH } fill = "var(--bg-surface)" rx = { 8 } />
88+
89+ { /* Near / far limit lines */ }
90+ < line x1 = { nearX } y1 = { PAD . top - 10 } x2 = { nearX } y2 = { svgH - PAD . bottom + 8 }
91+ stroke = "var(--accent)" strokeWidth = { 1.5 } strokeDasharray = "4 3" opacity = { 0.6 } / >
92+ < text x = { nearX } y = { PAD . top - 14 } fill = "var(--accent)" fontSize = { 10 }
93+ textAnchor = "middle" fontWeight = { 600 } > { t ( 'diagramNear' ) } </ text >
94+ < line x1 = { farX } y1 = { PAD . top - 10 } x2 = { farX } y2 = { svgH - PAD . bottom + 8 }
95+ stroke = "var(--accent)" strokeWidth = { 1.5 } strokeDasharray = "4 3" opacity = { 0.6 } />
96+ < text x = { farX } y = { PAD . top - 14 } fill = "var(--accent)" fontSize = { 10 }
97+ textAnchor = "middle" fontWeight = { 600 } > { t ( 'diagramFar' ) } </ text >
98+
99+ { /* === Coverage heatmap === */ }
100+ < text x = { PAD . left - 8 } y = { covY + COV_H / 2 + 3 } fill = "var(--text-secondary)"
101+ fontSize = { 9 } textAnchor = "end" fontWeight = { 500 } > { t ( 'diagramCoverage' ) } </ text >
102+ { cov . map ( ( count , i ) => count > 0 ? (
103+ < rect key = { i } x = { PAD . left + i * colW } y = { covY } width = { colW + 0.5 } height = { COV_H }
104+ fill = "var(--accent)"
105+ opacity = { 0.15 + Math . min ( 1 , count / Math . max ( 2 , maxCov * 0.6 ) ) * 0.55 } />
106+ ) : null ) }
107+ < rect x = { PAD . left } y = { covY } width = { DRAW_W } height = { COV_H }
108+ fill = "none" stroke = "var(--border)" strokeWidth = { 1 } rx = { 4 } />
109+ { maxCov > 1 && legendVals . map ( ( count , i ) => {
110+ const lx = VB_W - PAD . right - ( legendVals . length - 1 - i ) * 44 - 10
111+ const op = 0.15 + Math . min ( 1 , count / Math . max ( 2 , maxCov * 0.6 ) ) * 0.55
112+ return (
113+ < g key = { count } >
114+ < rect x = { lx } y = { covY - 14 } width = { 12 } height = { 8 }
115+ fill = "var(--accent)" opacity = { op } rx = { 2 } / >
116+ < text x = { lx + 16 } y = { covY - 7 } fill = "var(--text-secondary)" fontSize = { 8 } >
117+ { count } x
118+ </ text >
119+ </ g >
120+ )
121+ } ) }
122+
123+ { /* === Focus point dots === */ }
124+ < text x = { PAD . left - 8 } y = { focusY + 14 } fill = "var(--text-secondary)"
125+ fontSize = { 9 } textAnchor = "end" fontWeight = { 500 } > { t ( 'diagramFocusPoints' ) } </ text >
126+ < line x1 = { PAD . left } y1 = { focusY + 14 } x2 = { VB_W - PAD . right } y2 = { focusY + 14 }
127+ stroke = "var(--border)" strokeWidth = { 1 } />
119128 { shots . map ( ( shot , i ) => {
120- const color = BAND_COLORS [ i % BAND_COLORS . length ]
121- const y = PADDING_TOP + i * ( BAND_HEIGHT + BAND_GAP )
122- const x1 = distToX ( shot . nearFocus , logMin , logRange , drawWidth )
123- const x2 = distToX ( shot . farFocus , logMin , logRange , drawWidth )
124- const bandWidth = Math . max ( 2 , x2 - x1 )
125-
126- // Overlap zone with previous shot
127- let overlapX1 = 0
128- let overlapWidth = 0
129+ if ( i !== 0 && i !== shots . length - 1 && i % step !== 0 ) return null
130+ return (
131+ < circle key = { shot . number } cx = { distToX ( shot . focusDistance , logMin , logRange ) }
132+ cy = { focusY + 14 } r = { dotR } fill = { COLORS [ i % COLORS . length ] } opacity = { 0.85 } />
133+ )
134+ } ) }
135+ < text x = { VB_W - PAD . right + 4 } y = { focusY + 17 }
136+ fill = "var(--text-secondary)" fontSize = { 9 } > { shots . length } </ text >
137+
138+ { /* === Individual bands (few shots only) === */ }
139+ { showBands && shots . map ( ( shot , i ) => {
140+ const color = COLORS [ i % COLORS . length ]
141+ const by = bandsY + i * ( BAND_H + BAND_GAP )
142+ const x1 = distToX ( shot . nearFocus , logMin , logRange )
143+ const x2 = distToX ( shot . farFocus , logMin , logRange )
144+ const bw = Math . max ( 2 , x2 - x1 )
145+ const fx = distToX ( shot . focusDistance , logMin , logRange )
146+ let olW = 0 , olX = 0
129147 if ( i > 0 ) {
130148 const prev = shots [ i - 1 ]
131149 if ( prev . farFocus > shot . nearFocus ) {
132- overlapX1 = x1
133- const overlapX2 = distToX (
134- Math . min ( prev . farFocus , shot . farFocus ) ,
135- logMin , logRange , drawWidth ,
136- )
137- overlapWidth = Math . max ( 0 , overlapX2 - overlapX1 )
150+ olX = x1
151+ olW = Math . max ( 0 , distToX (
152+ Math . min ( prev . farFocus , shot . farFocus ) , logMin , logRange ,
153+ ) - x1 )
138154 }
139155 }
140-
141156 return (
142157 < g key = { shot . number } >
143- { /* Main band */ }
144- < rect
145- x = { x1 } y = { y }
146- width = { bandWidth } height = { BAND_HEIGHT }
147- fill = { color } opacity = { 0.35 } rx = { 3 }
148- />
149- < rect
150- x = { x1 } y = { y }
151- width = { bandWidth } height = { BAND_HEIGHT }
152- fill = "none" stroke = { color } strokeWidth = { 1.5 } rx = { 3 }
153- />
154-
155- { /* Overlap zone highlight */ }
156- { overlapWidth > 0 && (
157- < rect
158- x = { overlapX1 } y = { y }
159- width = { overlapWidth } height = { BAND_HEIGHT }
160- fill = { color } opacity = { 0.15 } rx = { 2 }
161- />
162- ) }
163-
164- { /* Focus point marker */ }
165- { ( ( ) => {
166- const fx = distToX ( shot . focusDistance , logMin , logRange , drawWidth )
167- return (
168- < circle cx = { fx } cy = { y + BAND_HEIGHT / 2 } r = { 3 } fill = { color } />
169- )
170- } ) ( ) }
171-
172- { /* Shot number label */ }
173- < text
174- x = { x1 - 6 } y = { y + BAND_HEIGHT / 2 + 4 }
175- fill = "var(--text-secondary)" fontSize = { 10 } textAnchor = "end"
176- fontWeight = { 500 }
177- >
178- { shot . number }
179- </ text >
158+ < rect x = { x1 } y = { by } width = { bw } height = { BAND_H } fill = { color } opacity = { 0.35 } rx = { 3 } />
159+ < rect x = { x1 } y = { by } width = { bw } height = { BAND_H }
160+ fill = "none" stroke = { color } strokeWidth = { 1.5 } rx = { 3 } />
161+ { olW > 0 && < rect x = { olX } y = { by } width = { olW } height = { BAND_H }
162+ fill = { color } opacity = { 0.15 } rx = { 2 } /> }
163+ < circle cx = { fx } cy = { by + BAND_H / 2 } r = { 3 } fill = { color } />
164+ < text x = { x1 - 6 } y = { by + BAND_H / 2 + 4 } fill = "var(--text-secondary)"
165+ fontSize = { 10 } textAnchor = "end" fontWeight = { 500 } > { shot . number } </ text >
180166 </ g >
181167 )
182168 } ) }
183169
184- { /* Distance scale at bottom */ }
170+ { /* === Distance axis === */ }
185171 { ticks . map ( ( d ) => {
186- const x = distToX ( d , logMin , logRange , drawWidth )
187- if ( x < PADDING_LEFT || x > viewBoxWidth - PADDING_RIGHT ) return null
188- const label = d >= 1 ? `${ d } m` : `${ Math . round ( d * 100 ) } cm`
172+ const x = distToX ( d , logMin , logRange )
173+ if ( x < PAD . left || x > VB_W - PAD . right ) return null
189174 return (
190175 < g key = { d } >
191- < line
192- x1 = { x } y1 = { svgHeight - PADDING_BOTTOM }
193- x2 = { x } y2 = { svgHeight - PADDING_BOTTOM + 6 }
194- stroke = "var(--text-secondary)" strokeWidth = { 1 } opacity = { 0.5 }
195- />
196- < text
197- x = { x } y = { svgHeight - PADDING_BOTTOM + 18 }
198- fill = "var(--text-secondary)" fontSize = { 10 } textAnchor = "middle"
199- >
200- { label }
201- </ text >
176+ < line x1 = { x } y1 = { svgH - PAD . bottom } x2 = { x } y2 = { svgH - PAD . bottom + 6 }
177+ stroke = "var(--text-secondary)" strokeWidth = { 1 } opacity = { 0.5 } />
178+ < text x = { x } y = { svgH - PAD . bottom + 18 } fill = "var(--text-secondary)"
179+ fontSize = { 10 } textAnchor = "middle" > { fmtDist ( d ) } </ text >
202180 </ g >
203181 )
204182 } ) }
205-
206- { /* Axis line */ }
207- < line
208- x1 = { PADDING_LEFT } y1 = { svgHeight - PADDING_BOTTOM }
209- x2 = { viewBoxWidth - PADDING_RIGHT } y2 = { svgHeight - PADDING_BOTTOM }
210- stroke = "var(--border)" strokeWidth = { 1 }
211- />
183+ < line x1 = { PAD . left } y1 = { svgH - PAD . bottom } x2 = { VB_W - PAD . right } y2 = { svgH - PAD . bottom }
184+ stroke = "var(--border)" strokeWidth = { 1 } />
212185 </ svg >
213186 )
214187}
0 commit comments