Added support for zooming X-Axis on bar graph

This commit is contained in:
Jeremy Rangel
2025-01-16 23:11:52 -08:00
parent 197c9fd3da
commit 249c188f58
9 changed files with 503 additions and 248 deletions

View File

@ -17,6 +17,14 @@
"style": "file:./style-index.css",
"viewScript": "file:./view.js",
"attributes": {
"drillDownAnimation": {
"type": "string",
"default": "none"
},
"enableXZoom": {
"type": "boolean",
"default": false
},
"enableHierarchicalView": {
"type": "boolean",
"default": false
@ -128,6 +136,10 @@
"enableStackedBars": {
"type": "boolean",
"default": false
},
"enableBarPopovers": {
"type": "boolean",
"default": false
}
}
}

View File

@ -1 +1 @@
<?php return array('dependencies' => array('react', 'react-jsx-runtime', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => 'e934cc621b90f4fd2b9c');
<?php return array('dependencies' => array('react', 'react-jsx-runtime', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-element', 'wp-i18n'), 'version' => '9fe03dfe50c4ea8c8985');

File diff suppressed because one or more lines are too long

View File

@ -17,6 +17,14 @@
"style": "file:./style-index.css",
"viewScript": "file:./view.js",
"attributes": {
"drillDownAnimation": {
"type": "string",
"default": "none"
},
"enableXZoom": {
"type": "boolean",
"default": false
},
"enableHierarchicalView": {
"type": "boolean",
"default": false
@ -128,6 +136,10 @@
"enableStackedBars": {
"type": "boolean",
"default": false
},
"enableBarPopovers": {
"type": "boolean",
"default": false
}
}
}

View File

@ -1,32 +1,36 @@
import { useEffect, useRef } from '@wordpress/element';
import { useEffect, useRef, useState } from '@wordpress/element';
import { Popover } from '@wordpress/components';
import * as d3 from 'd3';
const BarGraph = ({
data,
width = '100%',
height = '400px',
backgroundColor,
data,
width = '100%',
height = '400px',
enableXZoom = false,
backgroundColor = '#ffffff',
defaultBarColor = '#007cba',
barOpacity = 1,
title,
showGridX,
showGridY,
xGridColor,
yGridColor,
xGridWidth,
yGridWidth,
showBarValues,
showBarValues = false,
showGridX = false,
showGridY = false,
xGridColor = '#e0e0e0',
yGridColor = '#e0e0e0',
xGridWidth = 1,
yGridWidth = 1,
xAxisLabel,
yAxisLabel,
colorSource,
defaultBarColor,
customColors,
barOpacity,
colorSource = 'default',
customColors = [],
enableHierarchicalView,
enableStackedBars,
selectedParentId,
enableBarPopovers,
setAttributes
}) => {
const svgRef = useRef();
const containerRef = useRef();
const [hoveredBar, setHoveredBar] = useState(null);
// Helper function to darken a color
const darkenColor = (color, amount) => {
@ -38,23 +42,6 @@ const BarGraph = ({
).toString();
};
// Helper function to get children of a node
const getChildren = (nodeId) => {
const children = [];
Object.entries(data).forEach(([dataset, items]) => {
items.forEach(item => {
if (item.parent === nodeId) {
children.push({
...item,
dataset,
color: item.color || defaultBarColor
});
}
});
});
return children;
};
useEffect(() => {
if (!data || !svgRef.current || !containerRef.current) return;
@ -63,166 +50,170 @@ const BarGraph = ({
// Filter data based on hierarchical view
let displayData = data;
if (enableHierarchicalView && !enableStackedBars) {
displayData = {};
Object.entries(data).forEach(([dataset, items]) => {
const filteredItems = items.filter(item => item.parent === selectedParentId);
if (filteredItems.length > 0) {
displayData[dataset] = filteredItems;
}
});
}
let chartDataArray = [];
// Convert data to array format
const chartDataArray = [];
Object.entries(displayData).forEach(([dataset, items]) => {
items.forEach(item => {
if (!enableStackedBars || item.parent === null) {
const baseColor = item.color || defaultBarColor;
const children = getChildren(item.id);
// Create the main bar
const barData = {
id: item.id,
dataset,
label: item.label || '',
value: parseFloat(item.value) || 0,
color: baseColor,
hasChildren: children.length > 0,
children: []
};
// Process data into flat array format
Object.entries(data).forEach(([dataset, items]) => {
if (Array.isArray(items)) {
items.forEach(item => {
// Only process items that match the current hierarchical level
const isParentMatch = enableHierarchicalView ?
(item.parent === selectedParentId) :
(!item.parent || item.parent === null);
// If stacked bars are enabled, add children data
if (enableStackedBars && children.length > 0) {
let usedColors = new Set([baseColor]);
children.forEach((child, index) => {
let childColor = child.color || baseColor;
// If color is already used, darken it
while (usedColors.has(childColor)) {
childColor = darkenColor(childColor, 30);
}
usedColors.add(childColor);
barData.children.push({
id: child.id,
label: child.label,
value: parseFloat(child.value) || 0,
color: childColor
if (isParentMatch) {
const value = parseFloat(item.value);
if (!isNaN(value)) { // Only add items with valid numeric values
chartDataArray.push({
dataset,
label: item.label || '',
value: value,
color: item.color || defaultBarColor,
id: item.id,
popoverHtml: item.popoverHtml || '',
hasChildren: Array.isArray(data[dataset]) &&
data[dataset].some(child => child.parent === item.id)
});
});
}
}
chartDataArray.push(barData);
}
});
});
}
});
// Get container dimensions
const containerWidth = containerRef.current.clientWidth;
const containerHeight = typeof height === 'number' ? height : 400;
// If no valid data, set a default scale
if (chartDataArray.length === 0) {
chartDataArray = [{
label: 'No Data',
value: 0,
color: defaultBarColor
}];
}
// Get container dimensions from the actual DOM element
const containerWidth = containerRef.current.clientWidth;
const containerHeight = containerRef.current.clientHeight;
// Basic dimensions with extra margin for axis labels
const margin = {
top: title ? 40 : 20,
right: 20,
bottom: xAxisLabel ? 50 : 30,
left: yAxisLabel ? 60 : 40
bottom: 60,
left: 60
};
const innerWidth = Math.max(containerWidth - margin.left - margin.right, 0);
const innerHeight = Math.max(containerHeight - margin.top - margin.bottom, 0);
// Create SVG
const svg = d3.select(svgRef.current)
.attr('width', containerWidth)
.attr('height', containerHeight)
.attr('width', '100%')
.attr('height', '100%')
.attr('viewBox', `0 0 ${containerWidth} ${containerHeight}`)
.attr('preserveAspectRatio', 'xMidYMid meet')
.style('background-color', backgroundColor);
// Add title if present
if (title) {
svg.append('text')
.attr('x', containerWidth / 2)
.attr('y', 20)
.attr('text-anchor', 'middle')
.style('font-size', '16px')
.text(title);
}
// Create main group
const g = svg.append('g')
.attr('transform', `translate(${margin.left},${margin.top})`);
// Create scales
// Create scales with validated domains
const xScale = d3.scaleBand()
.domain(chartDataArray.map(d => d.label))
.range([0, innerWidth])
.padding(0.1);
// Find the maximum value with validation
const maxValue = Math.max(
d3.max(chartDataArray, d => d.value) || 0,
1 // Ensure we always have a positive scale
);
// Calculate a nice round maximum for the y-axis
const yAxisMax = (() => {
const magnitude = Math.pow(10, Math.floor(Math.log10(maxValue)));
const normalized = maxValue / magnitude;
return Math.ceil(normalized) * magnitude || 100; // Fallback to 100 if calculation results in 0
})();
// Create y scale with validated domain
const yScale = d3.scaleLinear()
.domain([0, d3.max(chartDataArray, d => {
if (enableStackedBars) {
return d.value + d.children.reduce((sum, child) => sum + child.value, 0);
}
return d.value;
}) || 100])
.domain([0, yAxisMax])
.range([innerHeight, 0]);
// Get color for a datapoint based on color source
const getColor = (datapoint) => {
switch (colorSource) {
case 'singleColor':
return defaultBarColor;
case 'customColors':
const customColor = customColors?.find(
c => c.dataset === datapoint.dataset && c.label === datapoint.label
);
return customColor ? customColor.color : datapoint.color;
case 'default':
default:
return datapoint.color;
}
};
// Create y-axis with nice tick values
const yAxis = d3.axisLeft(yScale)
.tickSize(6)
.ticks(10)
.tickFormat(d3.format(",d")); // Format numbers with commas for thousands
// Add clip path
const clipPath = svg.append('defs')
.append('clipPath')
.attr('id', 'clip')
.append('rect')
.attr('x', 0)
.attr('y', 0)
.attr('width', innerWidth)
.attr('height', innerHeight);
// Create container for all zoomable content
const zoomGroup = g.append('g')
.attr('class', 'zoom-group')
.attr('clip-path', 'url(#clip)');
// Add grids (behind bars)
if (showGridY) {
zoomGroup.append('g')
.attr('class', 'grid y-grid')
.style('color', yGridColor)
.style('stroke-width', yGridWidth)
.style('pointer-events', 'none')
.call(d3.axisLeft(yScale)
.tickSize(-innerWidth)
.ticks(yAxis.ticks()[0]) // Use same number of ticks as y-axis
.tickFormat(''));
}
// Add X grid
if (showGridX) {
g.append('g')
zoomGroup.append('g')
.attr('class', 'grid x-grid')
.attr('transform', `translate(0,${innerHeight})`)
.style('color', xGridColor)
.style('stroke-width', xGridWidth)
.style('pointer-events', 'none')
.call(d3.axisBottom(xScale)
.tickSize(-innerHeight)
.tickFormat(''));
}
// Add Y grid
if (showGridY) {
g.append('g')
.attr('class', 'grid y-grid')
.style('color', yGridColor)
.style('stroke-width', yGridWidth)
.call(d3.axisLeft(yScale)
.tickSize(-innerWidth)
.tickFormat(''));
}
// Add bars
const barGroups = g.selectAll('.bar-group')
// Create bar groups with validation
const barGroups = zoomGroup.selectAll('.bar-group')
.data(chartDataArray)
.enter()
.append('g')
.join('g')
.attr('class', 'bar-group')
.attr('transform', d => `translate(${xScale(d.label)},0)`);
// Add main bars
// Add bars with validation and click handling
barGroups.append('rect')
.attr('class', 'main-bar')
.attr('y', d => yScale(d.value))
.attr('class', 'bar')
.attr('x', 0)
.attr('y', d => isNaN(yScale(d.value)) ? yScale(0) : yScale(d.value))
.attr('width', xScale.bandwidth())
.attr('height', d => innerHeight - yScale(d.value))
.attr('fill', d => getColor(d))
.attr('height', d => {
const height = innerHeight - (isNaN(yScale(d.value)) ? yScale(0) : yScale(d.value));
return Math.max(0, height);
})
.attr('fill', d => d.color || defaultBarColor)
.style('opacity', barOpacity)
.style('cursor', d => (d.hasChildren && !enableStackedBars) ? 'pointer' : 'default');
.style('cursor', d => {
if (enableBarPopovers && d.popoverHtml) return 'pointer';
return (d.hasChildren && enableHierarchicalView) ? 'pointer' : 'default';
})
.on('click', (event, d) => {
if (d.hasChildren && enableHierarchicalView) {
event.stopPropagation();
setAttributes({ selectedParentId: d.id });
}
});
// Add stacked child bars if enabled
if (enableStackedBars) {
@ -242,87 +233,100 @@ const BarGraph = ({
});
}
// Add click handlers for hierarchical view
if (enableHierarchicalView && !enableStackedBars) {
barGroups.selectAll('.main-bar').on('click', function(event, d) {
if (d.hasChildren && setAttributes) {
setAttributes({ selectedParentId: d.id });
}
});
// Add back button if not at root level
if (selectedParentId !== null) {
svg.append('text')
.attr('x', margin.left)
.attr('y', margin.top / 2)
.attr('class', 'back-button')
.style('cursor', 'pointer')
.style('font-weight', 'bold')
.text('← Back')
.on('click', function() {
if (setAttributes) {
let parentParentId = null;
Object.values(data).forEach(items => {
const currentItem = items.find(item => item.id === selectedParentId);
if (currentItem) {
parentParentId = currentItem.parent;
}
});
setAttributes({ selectedParentId: parentParentId });
}
});
}
}
// Add bar values
// Add bar values with validation
if (showBarValues) {
barGroups.each(function(d) {
// Add value for main bar
d3.select(this)
.append('text')
.attr('class', 'bar-value')
.attr('x', xScale.bandwidth() / 2)
.attr('y', yScale(d.value) - 5)
.attr('text-anchor', 'middle')
.text(d.value);
// Add values for stacked bars if enabled
if (enableStackedBars) {
let yOffset = d.value;
d.children.forEach(child => {
d3.select(this)
.append('text')
.attr('class', 'bar-value')
.attr('x', xScale.bandwidth() / 2)
.attr('y', yScale(yOffset + child.value) - 5)
.attr('text-anchor', 'middle')
.text(child.value);
yOffset += child.value;
});
}
});
barGroups.append('text')
.attr('class', 'bar-value')
.attr('x', xScale.bandwidth() / 2)
.attr('y', d => {
const y = yScale(d.value);
return isNaN(y) ? yScale(0) - 5 : y - 5;
})
.attr('text-anchor', 'middle')
.style('fill', '#000')
.style('font-size', '12px')
.text(d => d3.format(',d')(d.value || 0));
}
// Add X axis
g.append('g')
// Add X axis with rotated labels
const xAxis = g.append('g')
.attr('class', 'x-axis')
.attr('transform', `translate(0,${innerHeight})`)
.call(d3.axisBottom(xScale))
.selectAll('text')
.call(d3.axisBottom(xScale));
xAxis.selectAll('text')
.style('text-anchor', 'end')
.attr('dx', '-.8em')
.attr('dy', '.15em')
.attr('transform', 'rotate(-45)');
// Add Y axis
g.append('g')
.call(d3.axisLeft(yScale));
// Add Y axis with nice ticks
const yAxisGroup = g.append('g')
.attr('class', 'y-axis')
.call(yAxis);
// Add X axis label
// Add zoom behavior
const zoom = d3.zoom()
.scaleExtent([1, 5]) // Minimum scale is 1 (original size)
.extent([[0, 0], [innerWidth, innerHeight]])
.translateExtent([[0, 0], [innerWidth, innerHeight]])
.on('zoom', (event) => {
const transform = event.transform;
// Calculate new scale for bars
const newBandwidth = xScale.bandwidth() * transform.k;
const newRange = [transform.x, transform.x + innerWidth * transform.k];
// Update bar positions
barGroups.attr('transform', d => {
const x = transform.x + (xScale(d.label) * transform.k);
return `translate(${x},0)`;
});
// Update bar widths
barGroups.selectAll('rect')
.attr('width', Math.max(newBandwidth, 1));
// Update bar value labels size and position
if (showBarValues) {
barGroups.selectAll('.bar-value')
.attr('x', newBandwidth / 2)
.style('font-size', `${Math.min(12 * transform.k, 24)}px`); // Scale font size with zoom, max 24px
}
// Update x-axis
xAxis.call(d3.axisBottom(xScale)
.scale(d3.scaleBand()
.domain(chartDataArray.map(d => d.label))
.range(newRange)
.padding(0.1)));
// Update x-grid if enabled
if (showGridX) {
zoomGroup.select('.x-grid')
.attr('transform', `translate(0,${innerHeight})`)
.call(d3.axisBottom(xScale)
.scale(d3.scaleBand()
.domain(chartDataArray.map(d => d.label))
.range(newRange)
.padding(0.1))
.tickSize(-innerHeight)
.tickFormat(''));
}
});
// Only add zoom behavior if enabled
if (enableXZoom) {
svg.call(zoom)
.call(zoom.transform, d3.zoomIdentity); // Reset to initial transform
}
// Add X axis label with proper spacing
if (xAxisLabel) {
g.append('text')
.attr('class', 'x-axis-label')
.attr('x', innerWidth / 2)
.attr('y', innerHeight + 40)
.attr('y', innerHeight + 25)
.attr('text-anchor', 'middle')
.style('font-size', '12px')
.text(xAxisLabel);
@ -340,7 +344,43 @@ const BarGraph = ({
.text(yAxisLabel);
}
}, [data, width, height, title, showGridX, showGridY, xGridColor, yGridColor, xGridWidth, yGridWidth, showBarValues, xAxisLabel, yAxisLabel, colorSource, defaultBarColor, customColors, barOpacity, enableHierarchicalView, enableStackedBars, selectedParentId]);
// Add "Back" button if we're in a child view
if (enableHierarchicalView && selectedParentId) {
const backButton = svg.append('g')
.attr('class', 'back-button')
.style('cursor', 'pointer')
.on('click', () => {
// Find the parent's parent
let parentParentId = null;
Object.values(data).forEach(items => {
if (Array.isArray(items)) {
const currentParent = items.find(item => item.id === selectedParentId);
if (currentParent) {
parentParentId = currentParent.parent;
}
}
});
setAttributes({ selectedParentId: parentParentId });
});
backButton.append('rect')
.attr('x', 10)
.attr('y', 10)
.attr('width', 60)
.attr('height', 24)
.attr('rx', 4)
.attr('fill', '#f0f0f0')
.attr('stroke', '#ccc');
backButton.append('text')
.attr('x', 40)
.attr('y', 26)
.attr('text-anchor', 'middle')
.style('font-size', '12px')
.text('← Back');
}
}, [data, width, height, title, showGridX, showGridY, xGridColor, yGridColor, xGridWidth, yGridWidth, showBarValues, xAxisLabel, yAxisLabel, colorSource, defaultBarColor, customColors, barOpacity, enableHierarchicalView, enableStackedBars, selectedParentId, enableBarPopovers, enableXZoom]);
return (
<div
@ -349,11 +389,34 @@ const BarGraph = ({
style={{
width,
height,
backgroundColor,
position: 'relative'
}}
>
<svg ref={svgRef}></svg>
<svg
ref={svgRef}
style={{
width: '100%',
height: '100%'
}}
></svg>
{hoveredBar && (
<div
className="bar-popover"
style={{
position: 'fixed',
left: hoveredBar.position.x,
top: hoveredBar.position.y,
transform: 'translate(-50%, -100%)',
backgroundColor: 'white',
padding: '8px',
borderRadius: '4px',
boxShadow: '0 2px 4px rgba(0,0,0,0.2)',
zIndex: 1000,
pointerEvents: 'none'
}}
dangerouslySetInnerHTML={{ __html: hoveredBar.data.popoverHtml }}
/>
)}
</div>
);
};

View File

@ -54,7 +54,10 @@ export default function Edit({ attributes, setAttributes }) {
chartCustomColors,
enableHierarchicalView,
enableStackedBars,
selectedParentId } = attributes;
selectedParentId,
enableBarPopovers,
enableXZoom
} = attributes;
const blockProps = useBlockProps();
@ -273,6 +276,12 @@ export default function Edit({ attributes, setAttributes }) {
label={__('Chart Height', 'lcp')}
value={chartHeight}
onChange={(value) => setAttributes({ chartHeight: value })}
/>
<ToggleControl
label={__('Enable X-Axis Zoom', 'lcp-data-blocks')}
checked={enableXZoom}
onChange={(value) => setAttributes({ enableXZoom: value })}
help={__('Allow zooming and panning along the X-axis', 'lcp-data-blocks')}
/>
</PanelBody>
<PanelBody title="Appearance">
@ -429,7 +438,12 @@ export default function Edit({ attributes, setAttributes }) {
if (tab.name === 'appearance') {
return (
<PanelBody>
<ToggleControl
label={__('Enable Bar Popovers', 'lcp-data-blocks')}
checked={enableBarPopovers}
onChange={(value) => setAttributes({ enableBarPopovers: value })}
help={__('Show HTML content when hovering over bars', 'lcp-data-blocks')}
/>
</PanelBody>
);
}
@ -481,30 +495,58 @@ export default function Edit({ attributes, setAttributes }) {
)}
{chartData && Object.keys(chartData).length > 0 ? (
<BarGraph
data={chartData}
height={chartHeight}
width={chartWidth}
backgroundColor={backgroundColor}
defaultBarColor={barColor}
barOpacity={barOpacity}
showGridX={showGridX}
showGridY={showGridY}
yGridColor={yGridColor}
xGridColor={xGridColor}
xGridWidth={xGridWidth}
yGridWidth={yGridWidth}
title={displayChartTitle ? chartTitle : ''}
showBarValues={showBarValues}
xAxisLabel={xAxisLabel}
yAxisLabel={yAxisLabel}
colorSource={chartColorSource}
customColors={chartCustomColors}
enableHierarchicalView={enableHierarchicalView}
enableStackedBars={enableStackedBars}
selectedParentId={selectedParentId}
setAttributes={setAttributes}
/>
<>
{selectedParentId && enableHierarchicalView && (
<button
onClick={() => setAttributes({ selectedParentId: null })}
style={{
marginBottom: '10px',
padding: '8px 16px',
backgroundColor: '#007cba',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '4px',
fontSize: '14px',
boxShadow: '0 2px 4px rgba(0,0,0,0.1)'
}}
onMouseEnter={e => e.target.style.backgroundColor = '#006ba1'}
onMouseLeave={e => e.target.style.backgroundColor = '#007cba'}
>
<span style={{ fontSize: '18px', lineHeight: 1 }}></span>
Back to Parent View
</button>
)}
<BarGraph
data={chartData}
width={chartWidth}
height={chartHeight}
enableXZoom={enableXZoom}
backgroundColor={backgroundColor}
defaultBarColor={barColor}
barOpacity={barOpacity}
showGridX={showGridX}
showGridY={showGridY}
yGridColor={yGridColor}
xGridColor={xGridColor}
xGridWidth={xGridWidth}
yGridWidth={yGridWidth}
title={displayChartTitle ? chartTitle : ''}
showBarValues={showBarValues}
xAxisLabel={xAxisLabel}
yAxisLabel={yAxisLabel}
colorSource={chartColorSource}
customColors={chartCustomColors}
enableHierarchicalView={enableHierarchicalView}
enableStackedBars={enableStackedBars}
selectedParentId={selectedParentId}
enableBarPopovers={enableBarPopovers}
setAttributes={setAttributes}
/>
</>
) : (
<div
className="lcp-bar-graph-placeholder"