@@ -2189,10 +2189,17 @@ class TrackplotElement extends HTMLElement {
21892189 // Clear stale content from Turbo cache restoration or Turbo Stream replace
21902190 this . innerHTML = ""
21912191
2192+ // Drill-down state
2193+ this . _drillConfig = this . chartConfig . components ?. find ( c => c . type === "drilldown" ) || null
2194+ this . _drillStack = [ ]
2195+ this . _originalData = this . chartConfig . data ? [ ...this . chartConfig . data ] : [ ]
2196+ this . _drillClickHandler = null
2197+
21922198 this . chart = new Chart ( this , this . chartConfig )
21932199 requestAnimationFrame ( ( ) => {
21942200 this . chart . render ( )
21952201 this . _dispatchRender ( )
2202+ if ( this . _drillConfig ) this . _setupDrillListener ( )
21962203 } )
21972204
21982205 this . _resizeTimeout = null
@@ -2216,20 +2223,27 @@ class TrackplotElement extends HTMLElement {
22162223 clearTimeout ( this . _resizeTimeout )
22172224 this . resizeObserver ?. disconnect ( )
22182225 document . removeEventListener ( "turbo:before-cache" , this . _turboCacheHandler )
2226+ this . _removeDrillListener ( )
22192227 this . chart ?. destroy ( )
22202228 this . chart = null
22212229 }
22222230
22232231 static get observedAttributes ( ) { return [ "config" ] }
22242232
22252233 attributeChangedCallback ( name , oldVal , newVal ) {
2226- if ( name === "config" && oldVal !== null && newVal ) {
2234+ if ( name === "config" && oldVal !== null && newVal && ! this . _internalUpdate ) {
22272235 try {
22282236 this . chartConfig = JSON . parse ( newVal )
2237+ this . _drillConfig = this . chartConfig . components ?. find ( c => c . type === "drilldown" ) || null
2238+ this . _drillStack = [ ]
2239+ this . _originalData = this . chartConfig . data ? [ ...this . chartConfig . data ] : [ ]
2240+ this . _removeDrillListener ( )
2241+ this . _removeBreadcrumb ( )
22292242 this . chart = new Chart ( this , this . chartConfig )
22302243 this . chart . animate = false
22312244 this . chart . render ( )
22322245 this . _dispatchRender ( )
2246+ if ( this . _drillConfig ) this . _setupDrillListener ( )
22332247 } catch ( e ) {
22342248 console . error ( "Trackplot: invalid config JSON" , e )
22352249 }
@@ -2241,21 +2255,37 @@ class TrackplotElement extends HTMLElement {
22412255 /** Replace chart data and re-render without animation. */
22422256 updateData ( newData ) {
22432257 if ( ! this . chartConfig ) return
2258+ this . _drillStack = [ ]
2259+ this . _originalData = [ ...newData ]
2260+ this . _removeBreadcrumb ( )
22442261 this . chartConfig . data = newData
22452262 this . _rebuildChart ( false )
22462263 }
22472264
22482265 /** Replace the full config object and re-render. */
22492266 updateConfig ( config ) {
22502267 this . chartConfig = config
2268+ this . _drillConfig = config . components ?. find ( c => c . type === "drilldown" ) || null
2269+ this . _drillStack = [ ]
2270+ this . _originalData = config . data ? [ ...config . data ] : [ ]
2271+ this . _removeDrillListener ( )
2272+ this . _removeBreadcrumb ( )
22512273 this . _rebuildChart ( false )
2274+ if ( this . _drillConfig ) this . _setupDrillListener ( )
22522275 }
22532276
22542277 /** Append new data points and re-render with sliding window. */
22552278 appendData ( newPoints , { maxPoints = 50 } = { } ) {
22562279 if ( ! this . chartConfig ) return
2280+ // Reset to root level if drilled in
2281+ if ( this . _drillStack . length > 0 ) {
2282+ this . _drillStack = [ ]
2283+ this . _removeBreadcrumb ( )
2284+ this . chartConfig . data = this . _originalData
2285+ }
22572286 const current = this . chartConfig . data || [ ]
22582287 this . chartConfig . data = [ ...current , ...newPoints ] . slice ( - maxPoints )
2288+ this . _originalData = [ ...this . chartConfig . data ]
22592289 this . _rebuildChart ( false )
22602290 this . dispatchEvent ( new CustomEvent ( "trackplot:data-update" , {
22612291 bubbles : true ,
@@ -2316,13 +2346,148 @@ class TrackplotElement extends HTMLElement {
23162346 this . chart . animate = animate
23172347 this . chart . render ( )
23182348 // Sync attribute so Turbo morphing sees current state
2349+ this . _internalUpdate = true
23192350 this . setAttribute ( "config" , JSON . stringify ( this . chartConfig ) )
2351+ this . _internalUpdate = false
23202352 this . _dispatchRender ( )
23212353 }
23222354
23232355 _dispatchRender ( ) {
23242356 this . dispatchEvent ( new CustomEvent ( "trackplot:render" , { bubbles : true } ) )
23252357 }
2358+
2359+ // ── Drill-down Public API ────────────────────────────────
2360+
2361+ /** Go back one drill level. Returns false if already at root. */
2362+ drillUp ( ) {
2363+ if ( this . _drillStack . length === 0 ) return false
2364+ const prev = this . _drillStack . pop ( )
2365+ this . chartConfig . data = prev . data
2366+ this . _rebuildChart ( true )
2367+ this . _renderBreadcrumb ( )
2368+ this . dispatchEvent ( new CustomEvent ( "trackplot:drillup" , {
2369+ bubbles : true ,
2370+ detail : { level : this . _drillStack . length }
2371+ } ) )
2372+ return true
2373+ }
2374+
2375+ /** Reset to root data from any drill depth. Returns false if already at root. */
2376+ drillReset ( ) {
2377+ if ( this . _drillStack . length === 0 ) return false
2378+ this . _drillStack = [ ]
2379+ this . chartConfig . data = [ ...this . _originalData ]
2380+ this . _rebuildChart ( true )
2381+ this . _removeBreadcrumb ( )
2382+ this . dispatchEvent ( new CustomEvent ( "trackplot:drillup" , {
2383+ bubbles : true ,
2384+ detail : { level : 0 }
2385+ } ) )
2386+ return true
2387+ }
2388+
2389+ // ── Drill-down Internals ─────────────────────────────────
2390+
2391+ _setupDrillListener ( ) {
2392+ this . _removeDrillListener ( )
2393+ this . _drillClickHandler = ( e ) => {
2394+ const { chartType, datum } = e . detail
2395+ if ( chartType !== "bar" && chartType !== "pie" && chartType !== "heatmap" && chartType !== "treemap" ) return
2396+
2397+ const drillKey = this . _drillConfig . key
2398+ const children = datum ?. [ drillKey ]
2399+ if ( ! Array . isArray ( children ) || children . length === 0 ) return
2400+
2401+ e . stopImmediatePropagation ( )
2402+
2403+ // Determine label from the datum
2404+ const xAxis = this . chartConfig . components ?. find ( c => c . type === "axis" && c . direction === "x" )
2405+ const pieSeries = this . chartConfig . components ?. find ( c => c . type === "pie" )
2406+ let label
2407+ if ( chartType === "pie" && pieSeries ?. label_key ) {
2408+ label = datum [ pieSeries . label_key ]
2409+ } else if ( xAxis ?. data_key ) {
2410+ label = datum [ xAxis . data_key ]
2411+ }
2412+ label = label ?? `Level ${ this . _drillStack . length + 1 } `
2413+
2414+ this . _drillStack . push ( { data : this . chartConfig . data , label } )
2415+ this . chartConfig . data = children
2416+ this . _rebuildChart ( true )
2417+ this . _renderBreadcrumb ( )
2418+ this . dispatchEvent ( new CustomEvent ( "trackplot:drilldown" , {
2419+ bubbles : true ,
2420+ detail : { level : this . _drillStack . length , datum, label }
2421+ } ) )
2422+ }
2423+ this . addEventListener ( "trackplot:click" , this . _drillClickHandler )
2424+ }
2425+
2426+ _removeDrillListener ( ) {
2427+ if ( this . _drillClickHandler ) {
2428+ this . removeEventListener ( "trackplot:click" , this . _drillClickHandler )
2429+ this . _drillClickHandler = null
2430+ }
2431+ }
2432+
2433+ _drillToLevel ( n ) {
2434+ while ( this . _drillStack . length > n ) {
2435+ const prev = this . _drillStack . pop ( )
2436+ this . chartConfig . data = prev . data
2437+ }
2438+ this . _rebuildChart ( true )
2439+ this . _renderBreadcrumb ( )
2440+ this . dispatchEvent ( new CustomEvent ( "trackplot:drillup" , {
2441+ bubbles : true ,
2442+ detail : { level : this . _drillStack . length }
2443+ } ) )
2444+ }
2445+
2446+ _renderBreadcrumb ( ) {
2447+ this . _removeBreadcrumb ( )
2448+ if ( this . _drillStack . length === 0 ) return
2449+
2450+ const t = this . chartConfig . theme || DEFAULT_THEME
2451+ const crumbDiv = document . createElement ( "div" )
2452+ crumbDiv . className = "trackplot-breadcrumb"
2453+ crumbDiv . style . cssText = `display:flex;align-items:center;gap:4px;padding:4px 8px;font-family:${ t . font || FONT } ;font-size:13px;color:${ t . text_color || "#374151" } ;`
2454+
2455+ // "All" link (root)
2456+ const allLink = document . createElement ( "span" )
2457+ allLink . textContent = "All"
2458+ allLink . style . cssText = "cursor:pointer;text-decoration:underline;"
2459+ allLink . addEventListener ( "click" , ( ) => this . _drillToLevel ( 0 ) )
2460+ crumbDiv . appendChild ( allLink )
2461+
2462+ // Intermediate levels
2463+ this . _drillStack . forEach ( ( entry , i ) => {
2464+ const sep = document . createElement ( "span" )
2465+ sep . textContent = " \u203A "
2466+ crumbDiv . appendChild ( sep )
2467+
2468+ if ( i < this . _drillStack . length - 1 ) {
2469+ const link = document . createElement ( "span" )
2470+ link . textContent = entry . label
2471+ link . style . cssText = "cursor:pointer;text-decoration:underline;"
2472+ const level = i + 1
2473+ link . addEventListener ( "click" , ( ) => this . _drillToLevel ( level ) )
2474+ crumbDiv . appendChild ( link )
2475+ } else {
2476+ // Current level (bold, not clickable)
2477+ const current = document . createElement ( "span" )
2478+ current . textContent = entry . label
2479+ current . style . fontWeight = "bold"
2480+ crumbDiv . appendChild ( current )
2481+ }
2482+ } )
2483+
2484+ this . insertBefore ( crumbDiv , this . firstChild )
2485+ }
2486+
2487+ _removeBreadcrumb ( ) {
2488+ const crumb = this . querySelector ( ".trackplot-breadcrumb" )
2489+ if ( crumb ) crumb . remove ( )
2490+ }
23262491}
23272492
23282493customElements . define ( "trackplot-chart" , TrackplotElement )
0 commit comments