charts/horizontalbar.mjs (599 lines of code) (raw):

import dataTools from "./shared/dataTools"; import ColorScale from "./shared/colorscale"; import colorPresets from "./constants/colors"; import { numberFormat, mustache, mobileCheck, setMinToMax, textPadding, textPaddingMobile, stackMin, stackMax, contains, getURLParams, getLabelFromColumn } from './shared/toolbelt'; import { addDrops } from "./shared/drops" import Dropdown from "./shared/dropdown"; import Tooltip from "./shared/tooltip"; import { drawShowMore } from "./shared/showmore"; import { addLabel, clickLogging } from './shared/arrows'; import Sonic from "./shared/sonic"; import { checkApp } from 'newsroom-dojo'; export default class Horizontalbar { constructor(settings) { this.settings = settings this.noisyChartsSetup = false this.sonic = null this.init() } init() { console.log('enableshowmore',this.settings.enableShowMore) drawShowMore(this.settings.enableShowMore) if (this.settings.tooltip != "") { this.tooltip = new Tooltip(this.settings.tooltip) } if (this.settings.dropdown.length > 0) { addDrops() this.dropdown = new Dropdown( "dataPicker", dataTools.getDropdown(this.settings.dropdown, this.settings.keys) ) this.dropdown.on("dropdown-change", (label) => { let data = this.settings.dropdown.find(d => d.label == label) let values = data.values.split(",").map(function (value) { return value.trim(); }); this.settings.xColumn = values[0] this.settings.stackedhorizontal = values if (this.settings.tooltip != "") { this.tooltip.updateTemplate(data.tooltip) } this.render() }) } this.render() } render() { let chart = this let { modules, type, colors, height, width, isMobile, title, subtitle, source, marginleft, margintop, marginbottom, marginright, tooltip, xAxisLabel, prefix, suffix, minX, yColumn, xColumn, data, datum, labels, userkey, keys, forceCentre, lines, periods, enableShowMore, aria, colorScheme, autoSort, dropdown, xMin, xMax, xFormat, xAxis, yAxis, stackedhorizontal, parseTime, columns } = this.settings console.log( `xAxisLabel: ${xAxisLabel}`) d3.select("#graphicContainer svg").remove() //xColumn = stackedhorizontal[0] // Changed this so it is set by default before this module is loaded stackedhorizontal = stackedhorizontal.filter(d => d != 'Color') const chartKey = d3.select("#chartKey") chartKey.html("") colors = new ColorScale() isMobile = mobileCheck() const keyColor = dataTools.getKeysColors({ keys: stackedhorizontal, userKey: userkey, option: { colorScheme : colorScheme } }) colors.set(keyColor.keys, keyColor.colors) let showTotals = (contains(keys,'Color')) ? true : false const columnsKey = keys.filter(d => d != "Color" && d != "keyCategory") let allValues = [] data.forEach((d) => { for (let i = 1; i < keys.length; i++) { allValues.push(d[keys[i]]) } }) console.log("data", data) datum = JSON.parse(JSON.stringify(data)) let wrangle = [] datum = datum.map(d => Object.keys(d).filter((key) => contains(key, [yColumn, ...stackedhorizontal]) || key === 'Color' || key === 'keyCategory').reduce((cur, key) => { if (contains(key, stackedhorizontal) && d[key] != null) { wrangle.push(Object.assign(cur, { [key]: d[key] })) } return Object.assign(cur, { [key]: d[key] }) }, {})) datum = wrangle let set = new Set(datum.map(d => d[yColumn])) let barheight = Array.from(set).length; datum.forEach((d) => { stackedhorizontal.forEach((key, i) => { d[key] = (d[key] == null) ? null : +d[key] }) d.Total = d3.sum(stackedhorizontal, (k) => +d[k]) d.negative = (d3.min(stackedhorizontal, (k) => +d[k]) >= 0) ? false : true d.extent = d3.min(stackedhorizontal, (k) => +d[k]) }) if (autoSort) { datum = datum.sort((a, b) => d3.descending(+a.Total, +b.Total)) } let sonicData = JSON.parse(JSON.stringify(data)) sonicData = sonicData.map(obj => { delete obj.Color; return obj; }); datum.forEach((d) => { let newData = {} columnsKey.forEach((key, i) => { newData[key] = d[key] }) // sonicData.push(newData) }) // console.log("sonicdata1", sonicData) // sonicData = sonicData.sort((a, b) => d3.ascending(+a[columnsKey[1]], +b[columnsKey[1]])) console.log("stack",stackedhorizontal, stackedhorizontal.length) width = document.querySelector("#graphicContainer").getBoundingClientRect().width - marginleft - marginright; height = (barheight) * 75 //+ margintop + marginbottom //width = width - marginleft - marginright const svg = d3 .select("#graphicContainer") .append("svg") .attr("width", width + marginleft + marginright) .attr("height", height + margintop + marginbottom) .attr("id", "svg") .attr("overflow", "hidden") const features = svg .append("g") .attr("transform","translate(" + marginleft + "," + margintop + ")") if (stackedhorizontal.length > 1 && !showTotals) { stackedhorizontal.forEach((k, i) => { const keyDiv = chartKey .append("div") .attr("class", "keyDiv") keyDiv .append("span") .attr("class", "keyCircle") .style("background-color", () => colors.get(k)) keyDiv .append("span") .attr("class", "keyText") .text(getLabelFromColumn(columns, k)) }) } const x = d3.scaleLinear() .range([0, width - marginright - marginleft]) const y = d3 .scaleBand() .range([0, height]) .paddingInner(0.45) .paddingOuter(0.45) y.domain(datum.map((d) => d[yColumn])) console.log("forceCentre", forceCentre) if (forceCentre) { const minMax = setMinToMax([...datum.map(d => d.Total), ...datum.map(d => d.extent)]) // (scaleByAllMax) ? getMinMax(allValues) : getMinMax(datum.map(d => d[1])) xMax = (xMax == "") ? minMax.max : xMax xMin = (xMin == "") ? minMax.min : xMin } else { const extent = d3.extent([...datum.map(d => d.Total), ...datum.map(d => d.extent)]) console.log('extent',extent) xMax = extent[1] xMin = extent[0] } if (minX != null) { if (minX != "") { xMin = parseInt(minX) } } x.domain([xMin, xMax]).nice() const xTicks = Math.round(width / 100) this.settings.audioRendering = 'categorical' // Don't run noisycharts in the apps until we can build a workaround let isApp = checkApp(); if (!isApp) { if (!chart.noisyChartsSetup) { chart.sonic = new Sonic(this.settings, sonicData, x, y, colors) chart.sonic.addInteraction('buttonContainer', 'showAudioControls') chart.noisyChartsSetup = true } let playButton = d3.select("#playChart") playButton .on("click", () => {sonic.playPause()}) } if (isApp) { d3.select("#showAudioControls").remove(); d3.select("#audioControl").remove(); } xAxis = g => g .attr("transform", `translate(0,${0})`) .attr("class", "axisgroup") .call(d3.axisTop(x).tickSizeOuter(0)) .call(d3.axisTop(x) .tickSize(-height, 0, 0) .ticks(xTicks) .tickFormat((d) => { return numberFormat(d) }) .tickPadding(10)) yAxis = g => g .call(d3.axisLeft(y)) features .append("g") .attr("class", "x") .attr("transform", "translate(0," + height + ")") .call(xAxis) var layers = d3.stack() .offset(d3.stackOffsetDiverging) .keys(stackedhorizontal)(Array.from(new Set(datum.map(e => e)))) layers.forEach(function(layer) { layer.forEach(function(subLayer) { subLayer.group = layer.key subLayer.groupValue = subLayer.data[layer.key] subLayer.total = subLayer.data.Total if (subLayer.data.Color) { subLayer.color = subLayer.data.Color } }) }) const layer = features .selectAll("layer") .data(layers, (d) => d.key) .enter() .append("g") .attr("class", (d) => "layer " + d.key) .attr("id", "features") layer .selectAll("rect") .attr("class", "rect") .data((d,i) => { return d }) .enter() .append("rect") .style("fill", (d, i) => { return (d.color) ? d.color : colors.get(d.group) }) .attr("x", (d) => { return x(d[0]) //(d[1] >= 0) ? x(0) : x(d[1]) }) .attr("y", (d) => { return y(d.data[yColumn]) }) .attr("class", "barPart") .attr("title", (d) => d.data[d.key]) .attr("data-group", (d) => d.group) .attr("data-count", (d) => d.data[d.key]) .attr("height", (d) => y.bandwidth()) // ) .attr("width", (d) => { return x(d[1]) - x(d[0]) //(d.data["Total"] >= 0) ? x(d.data["Total"]) - x(0) : x(0) - x(d.data["Total"]) // x(d[1]) }) features .selectAll(".barText") .data(Array.from(new Set(datum.map(e => e)))) .enter() .append("text") .attr("class", "barText") .attr("x", (d) => { return (!d.negative) ? x(0) + 5 : x(0) - 5 }) .attr("text-anchor",(d) => { return (!d.negative) ? "start" : "end" }) .attr("y", (d) => y(d[yColumn]) - 5) .text((d) => `${d[yColumn]}`) // Testing for label positions // ?key=1BJG_8rB8nkob0O7DsNU_oBpRq-5qsixNxfosp5AVphE&location=docsdata // ?key=oz-2023-school-state-federal-funding-change-grouped-bar&location=yacht-charter-data // ?key=2023-education-school-quartiles&location=yacht-charter-data // ?key=1DL9_rNNg3XVKodTmVHWO1DdABBRrOKVXQ5wshSK7pGw&location=docsdata // =============================== // Constants for Label Sizing // =============================== const LABEL_BUFFER = 10; // Buffer in pixels (formerly hardcoded as 10) const LABEL_CHAR_WIDTH = 6; // Approximate pixel width per character /** * Estimate the total width of a label in pixels. * * @param {string} label - The label text. * @param {number} [charWidth=LABEL_CHAR_WIDTH] - The multiplier for each character. * @returns {number} - Estimated label width (including buffer on both sides). */ function estimateLabelWidth(label, charWidth = LABEL_CHAR_WIDTH) { return label.length * charWidth + LABEL_BUFFER; } // ***************************************** // Helper Functions for Bar Label Placement // ***************************************** /** * Determine the “value” used to compute a bar’s width. * For stacked bars we expect d.Total or d.groupValue, * otherwise d.value is used. * * @param {Object} d - The data object for the bar. * @returns {number} - The value used to calculate the bar’s width. */ function getBarValue(d) { console.log(d); return d.Total != null ? d.Total : (d.groupValue != null ? d.groupValue : d.value); } /** * Calculate the x-coordinate for a non-stacked bar’s label. * * The label is placed inside the bar if it fits (with a left/right buffer) * and outside otherwise. * * @param {Object} d - The data object for the bar. * @param {Function} x - A scale function mapping data values to pixels. * @param {number} labelWidth - The estimated width of the label in pixels. * @param {number} [buffer=LABEL_BUFFER] - The buffer space (in pixels). * @returns {number} - The x position for the label. */ function calculateXPosition(d, x, labelWidth, buffer = LABEL_BUFFER) { const val = getBarValue(d); if (val === 0) return x(0); // Calculate the bar’s width in pixels (always a positive value). const barWidth = Math.abs(x(val) - x(0)); const fitsInside = (labelWidth) <= barWidth; if (val > 0) { // For positive bars, if the label fits, align it inside (flush with the right edge). return fitsInside ? x(val) - buffer : x(val) + buffer; } else { // For negative bars, if the label fits, align it inside (flush with the left edge). return fitsInside ? x(val) + buffer : x(val) - buffer; } } /** * Calculate the text-anchor for a non-stacked bar’s label. * * If the label fits within the bar: * - "end" for positive bars (right-aligned) * - "start" for negative bars (left-aligned) * Otherwise, the anchor is reversed so that the label is drawn outside. * * @param {Object} d - The data object for the bar. * @param {Function} x - Scale function mapping data values to pixels. * @param {number} labelWidth - The estimated label width. * @param {number} [buffer=LABEL_BUFFER] - The buffer in pixels. * @returns {string} - "start", "end", or "middle". */ function calculateTextAnchor(d, x, labelWidth, buffer = LABEL_BUFFER) { const val = getBarValue(d); if (val === 0) return "middle"; const barWidth = Math.abs(x(val) - x(0)); const fitsInside = (labelWidth) <= barWidth; if (val > 0) { return fitsInside ? "end" : "start"; } else { return fitsInside ? "start" : "end"; } } /** * Decide the text color for a non-stacked bar’s label. * * The assumption is that if the label fits inside the bar, it should contrast * with the bar’s color (e.g. white text on a dark bar). * * @param {Object} d - The data object for the bar. * @param {Function} x - Scale function mapping data values to pixels. * @param {number} labelWidth - The estimated label width. * @param {number} [buffer=LABEL_BUFFER] - Buffer in pixels. * @returns {string} - "white" or "black" */ function calculateTextColor(d, x, labelWidth, buffer = LABEL_BUFFER) { const val = getBarValue(d); if (val === 0) return "black"; const barWidth = Math.abs(x(val) - x(0)); const fitsInside = (labelWidth) <= barWidth; return fitsInside ? "white" : "black"; } // -------------------------------------------------- // Stacked Bar Label Functions // -------------------------------------------------- /** * Calculate the x-coordinate for a stacked bar’s label. * * Uses d.Total (or d.groupValue) for the cumulative value. * * @param {Object} d - The data object for the stacked bar. * @param {Function} x - Scale function mapping data values to pixels. * @param {number} labelWidth - The estimated label width. * @param {number} [buffer=LABEL_BUFFER] - Buffer in pixels. * @returns {number} - The computed x position for the label. */ function calculateXPositionStacked(d, x, labelWidth, buffer = LABEL_BUFFER) { if (d.Total > 0 || d.groupValue > 0) { const barEnd = x(d.Total || d[1]); return (labelWidth < barEnd) ? barEnd - buffer : barEnd + buffer; } else if (d.Total < 0 || d.groupValue < 0) { const barStart = x(d.Total || d[0]); return (x(0) - barStart > labelWidth) ? barStart - buffer : x(0) - buffer; } return x(0); } /** * Calculate the text-anchor for a stacked bar’s label. * * @param {Object} d - The data object for the stacked bar. * @param {Function} x - Scale function mapping data values to pixels. * @param {number} labelWidth - The estimated label width. * @returns {string} - "start" or "end". */ function calculateTextAnchorStacked(d, x, labelWidth) { if (d.Total > 0 || d.groupValue > 0) { return (labelWidth < x(d.Total || d[1])) ? "end" : "start"; } else if (d.Total < 0 || d.groupValue < 0) { return (x(0) - x(d.Total || d[0]) > labelWidth) ? "end" : "start"; } return "start"; } /** * Decide the text color for a stacked bar’s label. * * @param {Object} d - The data object for the stacked bar. * @param {Function} x - Scale function mapping data values to pixels. * @param {number} labelWidth - The estimated label width. * @returns {string} - "white" if the label fits, otherwise "black". */ function calculateTextColorStacked(d, x, labelWidth) { if ((d.Total > 0 || d.groupValue > 0) && (x(d.Total || d[1]) - x(0) > labelWidth)) { return "white"; } else if ((d.Total < 0 || d.groupValue < 0) && (x(0) - x(d.Total || d[0]) > labelWidth)) { return "white"; } return "black"; } /** * Build the label text using an optional prefix, a formatted number, and a suffix. * * @param {Object} d - The data object for the bar. * @param {string} prefix - A prefix to display before the number. * @param {Function} numberFormat - A function to format the number. * @param {string} suffix - A suffix to display after the number. * @returns {string} - The formatted label. */ function calculateLabel(d, prefix, numberFormat, suffix) { return `${prefix} ${numberFormat(d.Total || d.data[d.group])} ${suffix}`.trim(); } // ***************************************** // Main Rendering Logic // ***************************************** if (showTotals) { // When each datum represents one bar. layer .selectAll(".barNumber") .data(datum) .enter() .append("text") .attr("class", "barNumber") .style("font-weight", "bold") .attr("x", (d) => { const label = calculateLabel(d, prefix, numberFormat, suffix); // Use the helper to estimate label width. const labelWidth = estimateLabelWidth(label); return (d.total == d.groupValue) ? calculateXPosition(d, x, labelWidth) : calculateXPositionStacked(d, x, labelWidth); }) .attr("text-anchor", (d) => { const label = calculateLabel(d, prefix, numberFormat, suffix); const labelWidth = estimateLabelWidth(label); return (d.total == d.groupValue) ? calculateTextAnchor(d, x, labelWidth) : calculateTextAnchorStacked(d, x, labelWidth); }) .style("fill", (d) => { const label = calculateLabel(d, prefix, numberFormat, suffix); const labelWidth = estimateLabelWidth(label); return (d.total == d.groupValue) ? calculateTextColor(d, x, labelWidth) : calculateTextColorStacked(d, x, labelWidth); }) .attr("y", (d) => y(d[yColumn]) + (y.bandwidth() / 2 + 5)) .text((d) => calculateLabel(d, prefix, numberFormat, suffix)); } else { // When the data is in an array format (typically for stacked bars). layer .selectAll(".barNumber") .data((d) => d) .enter() .append("text") .attr("class", "barNumber") .style("font-weight", "bold") .attr("x", (d) => { const label = calculateLabel(d, prefix, numberFormat, suffix); // Consistently estimate the label width. const labelWidth = estimateLabelWidth(label); return (d.total == d.groupValue) ? calculateXPosition(d, x, labelWidth) : calculateXPositionStacked(d, x, labelWidth); }) .style("fill", (d) => { const label = calculateLabel(d, prefix, numberFormat, suffix); const labelWidth = estimateLabelWidth(label); return (d.total == d.groupValue) ? calculateTextColor(d, x, labelWidth) : calculateTextColorStacked(d, x, labelWidth); }) .attr("y", (d) => y(d.data[yColumn]) + (y.bandwidth() / 2 + 5)) .attr("text-anchor", (d) => { const label = calculateLabel(d, prefix, numberFormat, suffix); const labelWidth = estimateLabelWidth(label); return (d.total == d.groupValue) ? calculateTextAnchor(d, x, labelWidth) : calculateTextAnchorStacked(d, x, labelWidth); }) .text((d) => { const label = calculateLabel(d, prefix, numberFormat, suffix); const barWidth = x(d[1]) - x(d[0]); // Only display the label if the bar is wide enough. if (stackedhorizontal.length > 1 && barWidth < estimateLabelWidth(label)) { return " "; } return label; }); } // Draws a solid line at zero features.append('line') .style("stroke", "#767676") .style("stroke-width", 1) .attr("x1", x(0)) .attr("y1", 0) .attr("x2", x(0)) .attr("y2", height); if (this.settings.tooltip != "") { this.tooltip.bindEvents( d3.selectAll(".barPart"), width, height + margintop + marginbottom ) } if (xAxisLabel != "") { features .append("text") .attr("x", width - marginright) .attr("y", setXLabelPosition(margintop)) .attr("fill", "#767676") .attr("text-anchor", "end") .text(xAxisLabel) } function setXLabelPosition(mt) { return mt < 34 ? 6 : - (mt - 10) } if (labels.length > 0) { const clickLoggingOn = getURLParams("labelling") ? true : false; console.log("clickLoggingOn", clickLoggingOn); // Move this to wrangle later once we re-factor the labelling stuff if (typeof labels[0].coords === 'string') { labels.forEach(function(d) { d.coords = JSON.parse(d.coords) d.sweepFlag = +d.sweepFlag d.largeArcFlag = +d.largeArcFlag d.radius = +d.radius }) } console.log("annotations", labels) labels.forEach((config) => { addLabel(svg, config, width + marginleft + marginright, height + margintop + marginbottom, { "left": marginleft, "right": marginright, "top": margintop, "bottom": marginbottom }, clickLoggingOn) }) } } }