Skip to content

Commit a6b0a32

Browse files
committed
feat: add drilldown for pie, bar heatmap and treemap charts
1 parent 2a78740 commit a6b0a32

9 files changed

Lines changed: 811 additions & 1 deletion

File tree

README.md

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,72 @@ Charts gracefully handle empty data with a centered message:
439439

440440
The default message is "No data available". Customize it with `empty_message:`.
441441

442+
## Drill-Down
443+
444+
Click a bar, pie slice, heatmap cell, or treemap rectangle to zoom into nested sub-data. All drill levels are pre-loaded in the JSON config — no server round-trips required. A breadcrumb appears for navigation back.
445+
446+
```erb
447+
<%= trackplot_chart @data do |c| %>
448+
<% c.bar :revenue %>
449+
<% c.axis :x, data_key: :region %>
450+
<% c.axis :y, format: :currency %>
451+
<% c.drilldown :breakdown %>
452+
<% end %>
453+
```
454+
455+
Nest sub-data under the drill key. Multi-level drilling is supported:
456+
457+
```ruby
458+
@data = [
459+
{ region: "North", revenue: 500, breakdown: [
460+
{ region: "NYC", revenue: 200, breakdown: [
461+
{ region: "Manhattan", revenue: 120 },
462+
{ region: "Brooklyn", revenue: 80 }
463+
]},
464+
{ region: "Boston", revenue: 300 }
465+
]},
466+
{ region: "South", revenue: 300, breakdown: [
467+
{ region: "Atlanta", revenue: 300 }
468+
]}
469+
]
470+
```
471+
472+
Works with pie/donut charts too:
473+
474+
```erb
475+
<% c.pie :value, label_key: :name %>
476+
<% c.drilldown :breakdown %>
477+
```
478+
479+
Items without the drill key (leaf nodes) fire a normal `trackplot:click` event instead.
480+
481+
### Drill-Down JavaScript API
482+
483+
Navigate drill levels programmatically:
484+
485+
```javascript
486+
const chart = document.querySelector("trackplot-chart")
487+
488+
chart.drillUp() // Go back one level (returns false if at root)
489+
chart.drillReset() // Return to root from any depth (returns false if at root)
490+
```
491+
492+
### Drill-Down Events
493+
494+
```javascript
495+
// Fires after drilling into sub-data
496+
chart.addEventListener("trackplot:drilldown", (e) => {
497+
e.detail.level // current drill depth (1, 2, ...)
498+
e.detail.datum // the clicked datum
499+
e.detail.label // label of the drilled item ("North", "NYC", ...)
500+
})
501+
502+
// Fires after drilling back up
503+
chart.addEventListener("trackplot:drillup", (e) => {
504+
e.detail.level // new drill depth (0 = root)
505+
})
506+
```
507+
442508
## Click Events
443509

444510
Every interactive element (bars, dots, pie slices, funnel stages...) dispatches a `trackplot:click` CustomEvent that bubbles up the DOM:

app/assets/javascripts/trackplot/index.js

Lines changed: 166 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

23282493
customElements.define("trackplot-chart", TrackplotElement)

lib/trackplot.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ module Components
2828
autoload :Brush, "trackplot/components/brush"
2929
autoload :Heatmap, "trackplot/components/heatmap"
3030
autoload :Treemap, "trackplot/components/treemap"
31+
autoload :Drilldown, "trackplot/components/drilldown"
3132
end
3233

3334
# Optional integrations — loaded only when their dependencies are available

lib/trackplot/chart_builder.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,11 @@ def treemap(**opts)
101101
nil
102102
end
103103

104+
def drilldown(key, **opts)
105+
@components << Components::Drilldown.new(key: key, **opts)
106+
nil
107+
end
108+
104109
def render(view_context)
105110
chart_id = options[:id] || "trackplot-#{SecureRandom.hex(8)}"
106111
config = build_config
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
module Trackplot
2+
module Components
3+
class Drilldown < Base
4+
def to_config
5+
{
6+
type: "drilldown",
7+
key: options[:key].to_s
8+
}
9+
end
10+
end
11+
end
12+
end

0 commit comments

Comments
 (0)