1- import React , { useMemo } from 'react' ;
1+ import React , { useMemo , useState , useRef , useEffect } from 'react' ;
22import PriceRangeChart from './PriceRangeChart' ;
33import { aggregateDates } from '../utils/dateAggregation' ;
44import { aggregateMetricsForGroups , collectScalingValues } from '../utils/metricAggregation' ;
@@ -13,6 +13,22 @@ const sourceColors = {
1313 'plattauto' : '#43a047'
1414} ;
1515
16+ // Color palette for trims (similar to model colors in OverviewChart)
17+ const trimColorPalette = [
18+ '#667eea' , // Primary purple
19+ '#f56565' , // Red
20+ '#48bb78' , // Green
21+ '#ed8936' , // Orange
22+ '#4299e1' , // Blue
23+ '#9f7aea' , // Purple
24+ '#ed64a6' , // Pink
25+ '#38b2ac' , // Teal
26+ '#ecc94b' , // Yellow
27+ '#fc8181' , // Light red
28+ '#68d391' , // Light green
29+ '#f6ad55' // Light orange
30+ ] ;
31+
1632const toRgba = ( hex , alpha ) => {
1733 if ( ! hex ) return `rgba(102, 102, 102, ${ alpha } )` ;
1834 let normalized = hex . replace ( '#' , '' ) ;
@@ -40,13 +56,79 @@ export default function DetailChart({
4056 loading = false ,
4157 onSelectedDatePosition
4258} ) {
59+ const [ groupMode , setGroupMode ] = useState ( ( ) => {
60+ // Initialize from URL parameter
61+ const url = new URL ( window . location ) ;
62+ const groupParam = url . searchParams . get ( 'groupBy' ) ;
63+ return groupParam === 'trim' ? 'trim' : 'source' ;
64+ } ) ;
65+ const [ groupMenuOpen , setGroupMenuOpen ] = useState ( false ) ;
66+ const groupButtonRef = useRef ( null ) ;
67+
68+ // Handle URL parameter for group mode
69+ useEffect ( ( ) => {
70+ const url = new URL ( window . location ) ;
71+ const groupParam = url . searchParams . get ( 'groupBy' ) ;
72+ if ( groupParam ) {
73+ setGroupMode ( groupParam === 'trim' ? 'trim' : 'source' ) ;
74+ } else {
75+ setGroupMode ( 'source' ) ;
76+ }
77+ } , [ ] ) ;
78+
79+ // Handle browser back/forward for group mode
80+ useEffect ( ( ) => {
81+ const handlePopState = ( ) => {
82+ const url = new URL ( window . location ) ;
83+ const groupParam = url . searchParams . get ( 'groupBy' ) ;
84+ setGroupMode ( groupParam === 'trim' ? 'trim' : 'source' ) ;
85+ } ;
86+
87+ window . addEventListener ( 'popstate' , handlePopState ) ;
88+ return ( ) => window . removeEventListener ( 'popstate' , handlePopState ) ;
89+ } , [ ] ) ;
90+
91+ // Close dropdown when clicking outside
92+ useEffect ( ( ) => {
93+ if ( ! groupMenuOpen ) return ;
94+ const handleClickOutside = ( event ) => {
95+ if ( groupButtonRef . current && ! groupButtonRef . current . contains ( event . target ) ) {
96+ setGroupMenuOpen ( false ) ;
97+ }
98+ } ;
99+ document . addEventListener ( 'mousedown' , handleClickOutside ) ;
100+ return ( ) => document . removeEventListener ( 'mousedown' , handleClickOutside ) ;
101+ } , [ groupMenuOpen ] ) ;
102+
43103 const { datasets, dates } = useMemo ( ( ) => {
44104 if ( ! data || data . length === 0 || ! model ) {
45105 return { datasets : [ ] , dates : [ ] } ;
46106 }
47107
48- // Extract sources and dates
49- const sources = [ ...new Set ( data . map ( d => d . source ) ) ] ;
108+ // Extract groups (sources or trims) and dates
109+ let groups ;
110+ let colorMap ;
111+
112+ if ( groupMode === 'trim' ) {
113+ // Get all unique normalized trims for this model
114+ const trimSet = new Set ( ) ;
115+ data . forEach ( sourceData => {
116+ sourceData . listings
117+ . filter ( l => `${ l . make } ${ l . model } ` === model && l . normalized_trim )
118+ . forEach ( listing => trimSet . add ( listing . normalized_trim ) ) ;
119+ } ) ;
120+ groups = Array . from ( trimSet ) . sort ( ) ;
121+
122+ // Assign colors to trims
123+ colorMap = { } ;
124+ groups . forEach ( ( trim , index ) => {
125+ colorMap [ trim ] = trimColorPalette [ index % trimColorPalette . length ] ;
126+ } ) ;
127+ } else {
128+ // Use sources
129+ groups = [ ...new Set ( data . map ( d => d . source ) ) ] ;
130+ colorMap = sourceColors ;
131+ }
50132 const providedDates = Array . isArray ( dateLabels ) && dateLabels . length > 0
51133 ? dateLabels
52134 : null ;
@@ -58,19 +140,27 @@ export default function DetailChart({
58140 const dateAggregation = aggregateDates ( baseDates , availableDates ) ;
59141 const { dates, dateGroups } = dateAggregation ;
60142
61- // Aggregate metrics for all sources , filtered by model
143+ // Aggregate metrics for all groups , filtered by model and group
62144 const aggregatedMetrics = aggregateMetricsForGroups (
63145 data ,
64- sources ,
146+ groups ,
65147 baseDates ,
66148 dateAggregation ,
67- ( sourceData , source ) => {
68- if ( sourceData . source !== source ) {
69- return [ ] ;
149+ ( sourceData , group ) => {
150+ const modelListings = sourceData . listings . filter ( l => `${ l . make } ${ l . model } ` === model ) ;
151+
152+ if ( groupMode === 'trim' ) {
153+ // Filter by normalized_trim
154+ return modelListings
155+ . filter ( l => l . normalized_trim === group )
156+ . map ( listing => ( { ...listing , source : sourceData . source } ) ) ;
157+ } else {
158+ // Filter by source
159+ if ( sourceData . source !== group ) {
160+ return [ ] ;
161+ }
162+ return modelListings . map ( listing => ( { ...listing , source : sourceData . source } ) ) ;
70163 }
71- return sourceData . listings
72- . filter ( l => `${ l . make } ${ l . model } ` === model )
73- . map ( listing => ( { ...listing , source : sourceData . source } ) ) ;
74164 }
75165 ) ;
76166
@@ -82,8 +172,8 @@ export default function DetailChart({
82172 : ( ) => 5 ;
83173
84174 // Transform metrics into datasets
85- const datasets = sources . map ( source => {
86- const metricsMap = aggregatedMetrics . get ( source ) ;
175+ const datasets = groups . map ( group => {
176+ const metricsMap = aggregatedMetrics . get ( group ) ;
87177
88178 const avgPoints = [ ] ;
89179 const minPoints = [ ] ;
@@ -136,16 +226,21 @@ export default function DetailChart({
136226 pointRadiiDays . push ( baseRadiusDays * scaleFactor ) ;
137227 } ) ;
138228
139- const baseColor = sourceColors [ source ] || '#666' ;
229+ const baseColor = colorMap [ group ] || '#666' ;
140230
141231 // Use dark mode detection
142232 const prefersDark = typeof window !== 'undefined' && window . matchMedia
143233 ? window . matchMedia ( '(prefers-color-scheme: dark)' ) . matches
144234 : false ;
145235 const rangeFillColor = prefersDark ? toRgba ( baseColor , 0.6 ) : toRgba ( baseColor , 0.25 ) ;
146236
237+ // Format label based on group mode
238+ const label = groupMode === 'trim'
239+ ? group
240+ : ( group . charAt ( 0 ) . toUpperCase ( ) + group . slice ( 1 ) ) ;
241+
147242 return {
148- label : source . charAt ( 0 ) . toUpperCase ( ) + source . slice ( 1 ) ,
243+ label,
149244 data : avgPoints ,
150245 borderColor : baseColor ,
151246 backgroundColor : baseColor ,
@@ -158,7 +253,7 @@ export default function DetailChart({
158253 pointHitRadius : pointRadiiStock ,
159254 pointBackgroundColor : baseColor ,
160255 isAverageLine : true ,
161- modelName : source . charAt ( 0 ) . toUpperCase ( ) + source . slice ( 1 ) ,
256+ modelName : label ,
162257 order : 0 ,
163258 z : 10 ,
164259 color : baseColor ,
@@ -176,12 +271,69 @@ export default function DetailChart({
176271 hasAggregatedDates : dates . length !== baseDates . length
177272 } ;
178273 } ) . filter ( dataset => {
179- // Filter out sources with no data at all
274+ // Filter out groups with no data at all
180275 return dataset . data . some ( price => price !== null ) ;
181276 } ) ;
182277
183278 return { datasets, dates } ;
184- } , [ data , model , dateLabels , availableDates ] ) ;
279+ } , [ data , model , dateLabels , availableDates , groupMode ] ) ;
280+
281+ // Group mode selector component
282+ const groupModeSelector = (
283+ < div className = "group-mode-selector" ref = { groupButtonRef } >
284+ < button
285+ type = "button"
286+ className = "group-mode-button"
287+ onClick = { ( ) => setGroupMenuOpen ( ! groupMenuOpen ) }
288+ aria-label = "Change grouping mode"
289+ aria-expanded = { groupMenuOpen }
290+ >
291+ < svg width = "24" height = "24" viewBox = "0 0 24 24" fill = "none" xmlns = "http://www.w3.org/2000/svg" >
292+ < polyline points = "3,18 7,12 11,15 15,8 19,11 22,6" stroke = "currentColor" strokeWidth = "2.5" strokeLinecap = "round" strokeLinejoin = "round" fill = "none" />
293+ </ svg >
294+ </ button >
295+ { groupMenuOpen && (
296+ < div className = "group-mode-menu" >
297+ < button
298+ type = "button"
299+ className = { `group-mode-menu-item${ groupMode === 'source' ? ' active' : '' } ` }
300+ onClick = { ( ) => {
301+ setGroupMode ( 'source' ) ;
302+ const url = new URL ( window . location ) ;
303+ url . searchParams . delete ( 'groupBy' ) ;
304+ window . history . pushState ( { } , '' , url ) ;
305+ setTimeout ( ( ) => setGroupMenuOpen ( false ) , 300 ) ;
306+ } }
307+ >
308+ { groupMode === 'source' ? (
309+ < span className = "group-mode-menu-item__check" > ✓</ span >
310+ ) : (
311+ < span className = "group-mode-menu-item__spacer" > </ span >
312+ ) }
313+ By Source
314+ </ button >
315+ < button
316+ type = "button"
317+ className = { `group-mode-menu-item${ groupMode === 'trim' ? ' active' : '' } ` }
318+ onClick = { ( ) => {
319+ setGroupMode ( 'trim' ) ;
320+ const url = new URL ( window . location ) ;
321+ url . searchParams . set ( 'groupBy' , 'trim' ) ;
322+ window . history . pushState ( { } , '' , url ) ;
323+ setTimeout ( ( ) => setGroupMenuOpen ( false ) , 300 ) ;
324+ } }
325+ >
326+ { groupMode === 'trim' ? (
327+ < span className = "group-mode-menu-item__check" > ✓</ span >
328+ ) : (
329+ < span className = "group-mode-menu-item__spacer" > </ span >
330+ ) }
331+ By Trim
332+ </ button >
333+ </ div >
334+ ) }
335+ </ div >
336+ ) ;
185337
186338 return (
187339 < PriceRangeChart
@@ -198,6 +350,7 @@ export default function DetailChart({
198350 loading = { loading }
199351 enableItemNavigation = { false }
200352 onSelectedDatePosition = { onSelectedDatePosition }
353+ extraControls = { groupModeSelector }
201354 />
202355 ) ;
203356}
0 commit comments