Files
goaccess/resources/js/charts.js
T
simonchiang 7436bf6e2e v1.10.2 patches: continent persistence, city markers, dynamic map center
C changes:
- gstorage.c: encode continent into root key for --persist --restore survival
- gholder.c: decode continent from root + lat/lng storage
- commons.h: add lat/lng fields to GMetrics struct
- geoip2.c/geoip1.h: extract coordinates from MMDB
- json.c: output lat/lng in geolocation JSON

JS changes:
- Remove cityIndex guard for static HTML city dots
- TopoJSON lat/lng fallback for city coordinates
- updateMap fallback city collection
- Dynamic orthographic center based on visitor count
- Mercator stays at [0,15] for full world view

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-25 11:38:09 +08:00

1858 lines
47 KiB
JavaScript

/**
* ______ ___
* / ____/___ / | _____________ __________
* / / __/ __ \/ /| |/ ___/ ___/ _ \/ ___/ ___/
* / /_/ / /_/ / ___ / /__/ /__/ __(__ |__ )
* \____/\____/_/ |_\___/\___/\___/____/____/
*
* The MIT License (MIT)
* Copyright (c) 2009-2018 Gerardo Orellana <hello @ goaccess.io>
*/
'use strict';
// This is faster than calculating the exact length of each label.
// e.g., getComputedTextLength(), slice()...
function truncate(text, width) {
text.each(function () {
var parent = this.parentNode, $d3parent = d3.select(parent);
var gw = $d3parent.node().getBBox();
var x = (Math.min(gw.width, width) / 2) * -1;
// adjust wrapper <svg> width
if ('svg' == parent.nodeName) {
$d3parent.attr('width', width).attr('x', x);
}
// wrap <text> within an svg
else {
$d3parent.insert('svg', function () {
return this;
}.bind(this))
.attr('class', 'wrap-text')
.attr('width', width)
.attr('x', x)
.append(function () {
return this;
}.bind(this));
}
});
}
function WorldMap(selection) {
const maxLat = 84;
let path = null;
let projection = null;
let tlast = [0, 0];
let slast = null;
let opts = {};
let metric = 'hits';
let margin = {
top: 20,
right: 50,
bottom: 40,
left: 50
};
let width = 760;
let height = 170;
// default value; will be set externally
let projectionType = 'mercator';
let initialScale;
let panelName = null;
let cityIndex = null;
let citySizeScale = null;
function innerW() {
return width - margin.left - margin.right;
}
function innerH() {
return height - margin.top - margin.bottom;
}
function buildCityIndex() {
if (cityIndex) return;
var cityData = window.cities10m;
if (!cityData) return;
cityIndex = {};
var features = topojson.feature(cityData, cityData.objects.cities).features;
features.forEach(function(f) {
var p = f.properties;
var iso = (p.ISO_A2 || '').toUpperCase();
var asciiKey = iso + '|' + (p.NAMEASCII || '').toLowerCase().trim();
cityIndex[asciiKey] = f;
var nameKey = iso + '|' + (p.NAME || '').toLowerCase().trim();
if (nameKey !== asciiKey)
cityIndex[nameKey] = f;
});
}
function collectCities(country, code) {
var out = [];
if (!country.items) return out;
country.items.forEach(function(city) {
out.push({
data: city.data,
countryCode: code,
hits: city.hits.count,
visitors: city.visitors.count,
bytes: city.bytes.count,
lat: (function(){var k=code.toUpperCase()+"|"+city.data.toLowerCase().trim();var f=cityIndex[k];return f&&f.geometry&&f.geometry.coordinates?f.geometry.coordinates[1]:0;})(),
lng: (function(){var k=code.toUpperCase()+"|"+city.data.toLowerCase().trim();var f=cityIndex[k];return f&&f.geometry&&f.geometry.coordinates?f.geometry.coordinates[0]:0;})()
});
});
return out;
}
function isCityVisible(coords) {
if (projectionType !== 'orthographic') return true;
var rotate = projection.rotate();
var center = [-rotate[0], -rotate[1]];
return d3.geoDistance(coords, center) < Math.PI / 2;
}
function getMapRenderState() {
if (!panelName) return null;
var fullData = (GoAccess.getPanelData(panelName) || {}).data || [];
var expanded = GoAccess.Util.getProp(GoAccess.AppState, panelName + '.expanded') || {};
var allCountries = [];
var highlightCodes = new Set();
var expandedCountryCodes = new Set();
var cities = [];
var expandedContinents = new Set();
// pass 1: detect expanded continents and collect countries
fullData.forEach(function(continent) {
if (!continent.items) return;
var continentKey = GoAccess.Util.hashCode(continent.data);
if (expanded[continentKey]) {
expandedContinents.add(continentKey);
}
continent.items.forEach(function(country) {
var code = country.data.split(' ')[0];
allCountries.push({
data: country.data,
hits: country.hits.count,
visitors: country.visitors.count,
bytes: country.bytes.count,
region: continent.data
});
});
});
var hasExpandedContinent = expandedContinents.size > 0;
// pass 2: resolve each continent independently
fullData.forEach(function(continent) {
if (!continent.items) return;
var continentKey = GoAccess.Util.hashCode(continent.data);
var isContinentExpanded = expandedContinents.has(continentKey);
// if nothing expanded, treat all continents as expanded
if (!hasExpandedContinent) {
isContinentExpanded = true;
}
if (!isContinentExpanded) return;
// detect expanded countries inside this continent
var localExpandedCountries = new Set();
continent.items.forEach(function(country) {
var code = country.data.split(' ')[0];
var countryKey = GoAccess.Util.hashCode(country.data);
if (expanded[countryKey]) {
localExpandedCountries.add(code);
expandedCountryCodes.add(code);
}
});
var hasLocalExpanded = localExpandedCountries.size > 0;
continent.items.forEach(function(country) {
var code = country.data.split(' ')[0];
// highlight logic
if (hasLocalExpanded) {
if (localExpandedCountries.has(code)) {
highlightCodes.add(code);
}
} else {
highlightCodes.add(code);
}
// city logic
if (hasLocalExpanded) {
if (localExpandedCountries.has(code)) {
cities = cities.concat(collectCities(country, code));
}
} else {
cities = cities.concat(collectCities(country, code));
}
});
});
// nothing expanded at all -> show everything
if (!hasExpandedContinent) {
highlightCodes = null;
cities = [];
fullData.forEach(function(continent) {
if (!continent.items) return;
continent.items.forEach(function(country) {
var code = country.data.split(' ')[0];
cities = cities.concat(collectCities(country, code));
});
});
}
return {
allCountries: allCountries,
highlightCodes: highlightCodes,
expandedCountryCodes: expandedCountryCodes.size
? expandedCountryCodes
: null,
cities: cities
};
}
function formatTooltip(data) {
const d = {
...data
};
let out = {};
out[0] = GoAccess.Util.fmtValue(d['data'], 'str');
out[1] = metric === 'bytes' ? GoAccess.Util.fmtValue(d['bytes'], 'bytes') : d3.format(',')(d['hits']);
if (metric === 'hits') out[2] = d3.format(',')(d['visitors']);
const template = d3.select('#tpl-chart-tooltip')
.html();
return Hogan.compile(template)
.render({
'data': out
});
}
function mouseover(event, selection, data) {
const tooltip = selection.select('.chart-tooltip-wrap');
tooltip.html(formatTooltip(data))
.style('left', `${d3.pointer(event)[0] + 10}px`)
.style('top', `${d3.pointer(event)[1] + 10}px`)
.style('display', 'block');
}
function mouseout(selection) {
const tooltip = selection.select('.chart-tooltip-wrap');
tooltip.style('display', 'none');
}
function drawLegend(selection, colorScale) {
const legendHeight = 10;
const legendPadding = 10;
let svg = selection.select('.legend-svg');
if (svg.empty()) {
svg = selection.append('svg')
.attr('class', 'legend-svg')
.attr('width', width + margin.left + margin.right)
.attr('height', legendHeight + 2 * legendPadding);
}
let legend = svg.select('.legend');
if (legend.empty()) {
legend = svg.append('g')
.attr('class', 'legend')
.attr('transform', `translate(${margin.left}, ${legendPadding})`);
}
const legendData = colorScale.quantiles();
const legendRects = legend.selectAll('rect')
.data(legendData);
legendRects.enter()
.append('rect')
.merge(legendRects)
.attr('x', (d, i) => (i * (innerW())) / legendData.length)
.attr('y', 0)
.attr('width', innerW() / legendData.length)
.attr('height', legendHeight)
.style('fill', d => colorScale(d));
legendRects.exit()
.remove();
const legendTexts = legend.selectAll('text')
.data(legendData);
legendTexts.enter()
.append('text')
.merge(legendTexts)
.attr('x', (d, i) => (i * (innerW())) / legendData.length)
.attr('y', legendHeight + legendPadding)
.style('font-size', '10px')
.attr('text-anchor', 'middle')
.text(d => metric === 'bytes' ? GoAccess.Util.fmtValue(d, 'bytes') : d3.format(',')(d));
legendTexts.exit()
.remove();
}
function updateSphere(svg, g) {
if (projectionType === 'orthographic') {
let sphere = g.selectAll('.sphere')
.data([{
type: 'Sphere'
}]);
// Insert as first child to be behind countries
let sphereEnter = sphere.enter()
.insert('path', ':first-child')
.attr('class', 'sphere')
.attr('d', path)
.attr('fill', '#DDEEFF') /* Light blue for ocean */
.attr('opacity', 0);
sphere = sphereEnter.merge(sphere);
sphere.transition()
.duration(500)
.attr('opacity', 1);
} else {
// Remove sphere when not in orthographic projection
g.selectAll('.sphere')
.transition()
.duration(500)
.attr('opacity', 0)
.remove();
}
}
function updateMap(selection, svg, data, countries, countryNameToGeoJson) {
buildCityIndex();
var state = getMapRenderState();
var activeData;
if (state) {
activeData = state.allCountries;
} else {
// Fallback: flatten data the old way when panel is not set
activeData = data.reduce(function(acc, region) {
if (!region.items) acc.push(region);
else region.items.forEach(function(item) {
acc.push({
data: item.data,
hits: item.hits.count !== undefined ? item.hits.count : item.hits,
visitors: item.visitors.count !== undefined ? item.visitors.count : item.visitors,
bytes: item.bytes.count !== undefined ? item.bytes.count : item.bytes,
region: region.data
});
});
return acc;
}, []);
state = { allCountries: activeData, highlightCodes: null, expandedCountryCodes: null, cities: (function(){var c=[];data.forEach(function(e){if(!e.items)return;var is2=e.items.length>0&&!e.items[0].items;if(is2){c=c.concat(collectCities(e,e.data.split(" ")[0]));}else{e.items.forEach(function(s){if(s.items)c=c.concat(collectCities(s,s.data.split(" ")[0]));});}});return c;})() };
}
path = d3.geoPath()
.projection(projection);
const colorScale = d3.scaleQuantile()
.domain(activeData.map(d => d[metric]))
.range(['#eafff1', '#a7e3d7', '#6cc5c0', '#44a2b1', '#246e96']);
if (activeData.length)
drawLegend(selection, colorScale);
// Create a mapping from country code to data
const dataByName = {};
activeData.forEach(d => {
const k = d.data.split(' ')[0];
dataByName[k] = d;
});
// Add graticule (grid lines)
if (projectionType !== 'mercator') {
const graticule = d3.geoGraticule();
let grid = svg.select('g')
.selectAll('.graticule')
.data([graticule()]);
let gridEnter = grid.enter()
.append('path')
.attr('class', 'graticule')
.attr('d', path)
.attr('fill', 'none')
.attr('stroke', '#ccc')
.attr('stroke-width', 0.5)
.attr('stroke-dasharray', '3,3')
.attr('opacity', 0);
grid = gridEnter.merge(grid);
grid.transition()
.duration(500)
.attr('opacity', 0.4);
}
let country = svg.select('g')
.selectAll('.country')
.data(countries);
let countryEnter = country.enter()
.append('path')
.attr('class', 'country')
.attr('d', path)
.attr('opacity', 0);
country = countryEnter.merge(country)
.on('mouseover', function(event, d) {
const countryData = dataByName[d.id];
if (countryData) mouseover(event, selection, countryData);
})
.on('mouseout', function() {
mouseout(selection);
});
country.transition()
.duration(500)
.style('fill', function(d) {
const countryData = dataByName[d.id];
if (!countryData) return '#cccccc54';
if (state.highlightCodes) {
return state.highlightCodes.has(d.id)
? colorScale(countryData[metric])
: '#cccccc30';
}
return colorScale(countryData[metric]);
})
.attr('opacity', 1);
country.exit()
.transition()
.duration(500)
.attr('opacity', 0)
.remove();
// Update city markers
updateCities(selection, svg, state, colorScale);
}
function updateCities(selection, svg, state, colorScale) {
var g = svg.select('g');
var cityMarkers = [];
if (state.cities.length) {
state.cities.forEach(function(c) {
var key = c.countryCode.toUpperCase() + '|' + c.data.toLowerCase().trim();
var feature = cityIndex[key];
if (feature) {
cityMarkers.push({
feature: feature,
data: c.data,
hits: c.hits,
visitors: c.visitors,
bytes: c.bytes
});
}
});
}
var sizeExtent = d3.extent(cityMarkers, function(d) { return d[metric]; });
if (!sizeExtent[0] && !sizeExtent[1]) sizeExtent = [0, 1];
var sizeScale = d3.scaleSqrt()
.domain(sizeExtent)
.range([3, 10])
.clamp(true);
var strokeScale = d3.scaleSqrt()
.domain(sizeExtent)
.range([1, 2.5])
.clamp(true);
citySizeScale = sizeScale;
var circles = g.selectAll('.city-marker')
.data(cityMarkers, function(d) { return d.data + '|' + d.feature.properties.ISO_A2; });
circles.exit()
.transition().duration(300)
.attr('r', 0)
.remove();
var circlesEnter = circles.enter()
.append('circle')
.attr('class', 'city-marker')
.attr('r', 0)
.style('fill', '#ff6b6b')
.style('stroke', '#fff')
.style('cursor', 'pointer')
.on('mouseover', function(event, d) {
mouseover(event, selection, d);
})
.on('mouseout', function() {
mouseout(selection);
});
circlesEnter.merge(circles)
.transition().duration(500)
.attr('cx', function(d) {
if (!isCityVisible(d.feature.geometry.coordinates)) return -9999;
var coords = projection(d.feature.geometry.coordinates);
return coords ? coords[0] : -9999;
})
.attr('cy', function(d) {
if (!isCityVisible(d.feature.geometry.coordinates)) return -9999;
var coords = projection(d.feature.geometry.coordinates);
return coords ? coords[1] : -9999;
})
.attr('r', function(d) {
if (!isCityVisible(d.feature.geometry.coordinates)) return 0;
var coords = projection(d.feature.geometry.coordinates);
return coords ? sizeScale(d[metric]) : 0;
})
.style('fill', '#ff6b6b')
.style('stroke', '#fff')
.style('stroke-width', function(d) {
return strokeScale(d[metric]) + 'px';
});
}
function setBounds(projection, maxLat) {
// Top-left corner of the ~viewable~ lat
const [yaw] = projection.rotate();
const xymax = projection([-yaw + 180 - 1e-6, -maxLat]);
const xymin = projection([-yaw - 180 + 1e-6, maxLat]);
return [xymin, xymax];
}
function clampVertical(projection, maxLat, height) {
const b = setBounds(projection, maxLat);
// top-left
const [xMin, yMin] = b[0];
// bottom-right
const [xMax, yMax] = b[1];
const t = projection.translate();
// If the top (yMin) is now below 0, shift upward.
if (yMin > 0)
t[1] -= yMin;
// If the bottom (yMax) is now above the container height, shift downward.
else if (yMax < height)
t[1] += (height - yMax);
projection.translate(t);
}
function baseScale() {
// If the effective width is small, use a fixed mobile scale; otherwise use the computed scale.
return innerW() < 400 ? 150 : innerW() / (2 * Math.PI);
}
var continentCenters = {
AS: [104, 35], EU: [10, 50], AF: [20, 0],
NA: [-100, 40], SA: [-60, -15], OC: [135, -25],
AN: [0, -90]
};
function getBestCenter() {
var data = GoAccess.getPanelData(panelName) || [];
var bestCode = null, bestVisitors = 0;
data.forEach(function(entry) {
if (!entry.items) return;
var code = entry.data.split(" ")[0];
var v = entry.visitors && entry.visitors.count || 0;
if (continentCenters[code] && v > bestVisitors) {
bestVisitors = v;
bestCode = code;
}
});
if (bestCode) return continentCenters[bestCode];
return [0, 15];
}
function setProjection(type) {
if (type === 'mercator') {
var ctr = getBestCenter();
const bScale = baseScale();
projection = d3.geoMercator()
.center(ctr)
.scale(bScale)
.translate([innerW() / 2, height / 1.5]);
} else if (type === 'orthographic') {
var ctr = getBestCenter();
const globeScale = Math.min(innerW(), height) / 1.5;
projection = d3.geoOrthographic()
.scale(globeScale)
.translate([innerW() / 2, height / 2])
.rotate([-ctr[0], -ctr[1], 0]);
}
initialScale = projection.scale();
path = d3.geoPath().projection(projection);
}
function zoomed(event, projection, path, scaleExtent, g) {
const scale = event.transform.k;
if (projectionType === 'mercator') {
const containerCenter = [innerW() / 2, height / 2];
let anchor = containerCenter;
if (Math.abs(scale - slast) > 1e-3) {
const geoCoord = projection.invert(anchor);
// Use baseScale() here instead of innerW()/(2*Math.PI)
projection.scale(scale * baseScale());
const newPixel = projection(geoCoord);
const dxAnchor = anchor[0] - newPixel[0];
const dyAnchor = anchor[1] - newPixel[1];
const t = projection.translate();
projection.translate([t[0] + dxAnchor, t[1] + dyAnchor]);
clampVertical(projection, maxLat, height);
} else {
const newX = event.transform.x;
const newY = event.transform.y;
const dx = newX - tlast[0];
const dy = newY - tlast[1];
let [longitude] = projection.rotate();
longitude += 360 * (dx / width) * (scaleExtent[0] / scale);
projection.rotate([longitude, 0, 0]);
const t = projection.translate();
t[1] += dy;
projection.translate(t);
clampVertical(projection, maxLat, height);
}
}
else if (projectionType === 'orthographic') {
// Use the zoom transform's k to compute a new scale,
// clamping to a maximum factor (here, 2x the initialScale).
let newScale = scale * initialScale;
const maxOrthoScale = initialScale * 2;
if (newScale > maxOrthoScale) newScale = maxOrthoScale;
if (newScale < initialScale) newScale = initialScale;
projection.scale(newScale);
// Update rotation using the difference between the current and previous transform values.
const dx = event.transform.x - tlast[0];
const dy = event.transform.y - tlast[1];
let [longitude, latitude] = projection.rotate();
// we could reduce sensitivity for smoother rotation
const sensitivity = 0.2;
longitude += dx * sensitivity;
latitude -= dy * sensitivity;
latitude = Math.max(-90, Math.min(90, latitude));
projection.rotate([longitude, latitude, 0]);
}
g.selectAll('path')
.attr('d', path);
g.selectAll('.city-marker')
.attr('cx', function(d) {
if (!isCityVisible(d.feature.geometry.coordinates)) return -9999;
var coords = projection(d.feature.geometry.coordinates);
return coords ? coords[0] : -9999;
})
.attr('cy', function(d) {
if (!isCityVisible(d.feature.geometry.coordinates)) return -9999;
var coords = projection(d.feature.geometry.coordinates);
return coords ? coords[1] : -9999;
})
.attr('r', function(d) {
if (!isCityVisible(d.feature.geometry.coordinates)) return 0;
return citySizeScale ? citySizeScale(d[metric]) : 0;
});
tlast = [event.transform.x, event.transform.y];
slast = scale;
}
function createSVG(selectionElem) {
const svg = d3.select(selectionElem)
.append('svg')
.attr('class', 'map')
.attr('style', 'display:block; margin:auto;')
.attr('width', innerW())
.attr('height', height)
.lower();
const g = svg.append('g')
.attr('transform', 'translate(0,0)');
// Use the externally provided projectionType
setProjection(projectionType);
// Compute scale extent based on projection type.
let scaleExtent;
if (projectionType === 'mercator') {
// For cellphones (narrow screens), use a fixed scale and limit zoom-out to the initial state.
if (innerW() < 400) {
// Here 1 means the initial zoom level (no zoom-out), 6 is arbitrary for max zoom in.
scaleExtent = [1, 6];
} else {
// Otherwise, calculate the base scale from the bounds.
const bounds = setBounds(projection, maxLat);
const s = innerW() / (bounds[1][0] - bounds[0][0]);
scaleExtent = [s, 6 * s];
}
} else if (projectionType === 'orthographic') {
// For orthographic, let d3.zoom use a relative scale factor.
// The identity transform (k = 1) corresponds to the initialScale,
// and we allow zooming from 1x to 2x.
scaleExtent = [1, 2];
}
const zoom = d3.zoom()
.scaleExtent(scaleExtent)
.on('zoom', event => {
zoomed(event, projection, path, scaleExtent, g);
});
svg.call(zoom);
return {
svg,
g
};
}
function chart(selectionData) {
selectionData.each(function(data) {
const worldData = window.countries110m;
const countries = topojson.feature(worldData, worldData.objects.countries)
.features; /* Build a mapping from country names to GeoJSON if needed */
const countryNameToGeoJson = {};
countries.forEach(country => {
countryNameToGeoJson[country.properties.name] = country;
});
let svgObj = d3.select(this)
.select('svg.map');
let g;
if (svgObj.empty()) {
const created = createSVG(this);
svgObj = created.svg;
g = created.g;
} else {
/* If the SVG already exists, select its group */
g = svgObj.select('g');
}
updateMap(d3.select(this), svgObj, data, countries, countryNameToGeoJson); /* Update sphere in case the projection is orthographic */
updateSphere(svgObj, g);
});
}
// Getter-setter for metric
chart.metric = function(_) {
if (!arguments.length) return metric;
metric = _;
return chart;
};
// Getter-setter for opts
chart.opts = function(_) {
if (!arguments.length) return opts;
opts = _;
return chart;
};
// Getter-setter for width
chart.width = function(_) {
if (!arguments.length) return width;
width = _;
return chart;
};
// Getter-setter for height
chart.height = function(_) {
if (!arguments.length) return height;
height = _;
return chart;
};
// Getter-setter for projectionType
chart.projectionType = function(_) {
if (!arguments.length) return projectionType;
projectionType = _;
return chart;
};
// Getter-setter for panel name
chart.panel = function(_) {
if (!arguments.length) return panelName;
panelName = _;
return chart;
};
return chart;
}
function AreaChart(dualYaxis) {
var opts = {};
var margin = {
top : 20,
right : 50,
bottom : 40,
left : 50,
},
height = 170,
nTicks = 10,
padding = 10,
width = 760;
var labels = { x: 'Unnamed', y0: 'Unnamed', y1: 'Unnamed' };
var format = { x: null, y0: null, y1: null};
var xValue = function (d) {
return d[0];
},
yValue0 = function (d) {
return d[1];
},
yValue1 = function (d) {
return d[2];
};
var xScale = d3.scaleBand();
var yScale0 = d3.scaleLinear().nice();
var yScale1 = d3.scaleLinear().nice();
var xAxis = d3.axisBottom(xScale)
.tickFormat(function(d) {
if (format.x)
return GoAccess.Util.fmtValue(d, format.x);
return d;
});
var yAxis0 = d3.axisLeft(yScale0)
.tickFormat(function(d) {
return d3.format('.2s')(d);
});
var yAxis1 = d3.axisRight(yScale1)
.tickFormat(function(d) {
if (format.y1)
return GoAccess.Util.fmtValue(d, format.y1);
return d3.format('.2s')(d);
});
var xGrid = d3.axisBottom(xScale);
var yGrid = d3.axisLeft(yScale0);
var area0 = d3.area()
.curve(d3.curveMonotoneX)
.x(X)
.y0(height)
.y1(Y0);
var area1 = d3.area()
.curve(d3.curveMonotoneX)
.x(X)
.y0(Y1)
.y1(height);
var line0 = d3.line()
.curve(d3.curveMonotoneX)
.x(X)
.y(Y0);
var line1 = d3.line()
.curve(d3.curveMonotoneX)
.x(X)
.y(Y1);
// The x-accessor for the path generator; xScale xValue.
function X(d) {
return (xScale(d[0]) + xScale.bandwidth() / 2);
}
// The x-accessor for the path generator; yScale0 yValue0.
function Y0(d) {
return yScale0(d[1]);
}
// The x-accessor for the path generator; yScale0 yValue0.
function Y1(d) {
return yScale1(d[2]);
}
function innerW() {
return width - margin.left - margin.right;
}
function innerH() {
return height - margin.top - margin.bottom;
}
function getXTicks(data) {
const domain = xScale.domain();
if (data.length < nTicks)
return domain;
return d3.range(0, nTicks).map(function(i) {
const index = Math.floor(i * (domain.length - 1) / (nTicks - 1));
if (index >= 0 && index < domain.length)
return domain[index];
return null;
});
}
function getYTicks(scale) {
var domain = scale.domain();
return d3.range(domain[0], domain[1], Math.ceil(domain[1] / nTicks));
}
// Convert data to standard representation greedily;
// this is needed for nondeterministic accessors.
function mapData(data) {
var _datum = function (d, i) {
var datum = [xValue.call(data, d, i), yValue0.call(data, d, i)];
dualYaxis && datum.push(yValue1.call(data, d, i));
return datum;
};
return data.map(function (d, i) {
return _datum(d, i);
});
}
function updateScales(data) {
// Update the x-scale.
xScale.domain(data.map(function (d) {
return d[0];
}))
.range([0, innerW()]);
// Update the y-scale.
yScale0.domain([0, d3.max(data, function (d) {
return d[1];
})])
.range([innerH(), 0]);
// Update the y-scale.
dualYaxis && yScale1.domain([0, d3.max(data, function (d) {
return d[2];
})])
.range([innerH(), 0]);
}
function toggleOpacity(ele, op) {
d3.select(ele.parentNode).selectAll('.' + (ele.getAttribute('data-yaxis') == 'y0' ? 'y1' : 'y0')).attr('style', op);
}
function setLegendLabels(svg) {
// Legend Color
var rect = svg.selectAll('rect.legend.y0').data([null]);
var rectEnter = rect.enter()
.append('rect')
.attr('class', 'legend y0')
.attr('data-yaxis', 'y0')
.on('mousemove', function(d, i) {
toggleOpacity(this, 'opacity:0.1');
})
.on('mouseleave', function(d, i) {
toggleOpacity(this, null);
})
.attr('y', (height - 15));
rectEnter.merge(rect)
.attr('x', (width / 2) - 100);
// Legend Labels
var text = svg.selectAll('text.legend.y0').data([null]);
var textEnter = text.enter()
.append('text')
.attr('class', 'legend y0')
.attr('data-yaxis', 'y0')
.on('mousemove', function(d, i) {
toggleOpacity(this, 'opacity:0.1');
})
.on('mouseleave', function(d, i) {
toggleOpacity(this, null);
})
.attr('y', (height - 6));
textEnter.merge(text)
.attr('x', (width / 2) - 85)
.text(labels.y0);
if (!dualYaxis)
return;
// Legend Labels
rect = svg.selectAll('rect.legend.y1').data([null]);
var rectEnter = rect.enter()
.append('rect')
.attr('class', 'legend y1')
.attr('data-yaxis', 'y1')
.on('mousemove', function(d, i) {
toggleOpacity(this, 'opacity:0.1');
})
.on('mouseleave', function(d, i) {
toggleOpacity(this, null);
})
.attr('y', (height - 15));
rectEnter.merge(rect)
.attr('x', (width / 2));
// Legend Labels
text = svg.selectAll('text.legend.y1').data([null]);
var textEnter = text.enter()
.append('text')
.attr('class', 'legend y1')
.attr('data-yaxis', 'y1')
.on('mousemove', function(d, i) {
toggleOpacity(this, 'opacity:0.1');
})
.on('mouseleave', function(d, i) {
toggleOpacity(this, null);
})
.attr('y', (height - 6));
textEnter.merge(text)
.attr('x', (width / 2) + 15)
.text(labels.y1);
}
function setAxisLabels(svg) {
// Labels
svg.selectAll('text.axis-label.y0')
.data([null])
.enter()
.append('text')
.attr('class', 'axis-label y0')
.attr('y', 10)
.attr('x', 53)
.text(labels.y0);
if (!dualYaxis) return;
// Labels
var tEnter = svg.selectAll('text.axis-label.y1')
.data([null])
.enter()
.append('text')
.attr('class', 'axis-label y1')
.attr('y', 10)
.text(labels.y1);
dualYaxis && tEnter.attr('x', width - 25);
}
function createSkeleton(svg) {
const g = svg.append('g');
// Lines
g.append('g')
.attr('class', 'line line0 y0');
dualYaxis && g.append('g')
.attr('class', 'line line1 y1');
// Areas
g.append('g')
.attr('class', 'area area0 y0');
dualYaxis && g.append('g')
.attr('class', 'area area1 y1');
// Points
g.append('g')
.attr('class', 'points y0');
dualYaxis && g.append('g')
.attr('class', 'points y1');
// Grid
g.append('g')
.attr('class', 'x grid');
g.append('g')
.attr('class', 'y grid');
// Axis
g.append('g')
.attr('class', 'x axis');
g.append('g')
.attr('class', 'y0 axis');
dualYaxis && g.append('g')
.attr('class', 'y1 axis');
// Rects
g.append('g')
.attr('class', 'rects');
setAxisLabels(svg);
setLegendLabels(svg);
// Mouseover line
g.append('line')
.attr('y2', innerH())
.attr('y1', 0)
.attr('class', 'indicator');
}
function pathLen(d) {
return d.node().getTotalLength();
}
function addLine(g, data, line, cName) {
// Update the line path.
var path = g.select('g.' + cName).selectAll('path.' + cName).data([data]);
// enter
var pathEnter = path.enter()
.append('svg:path')
.attr('d', line)
.attr('class', cName)
.attr('stroke-dasharray', function(d) {
var pl = pathLen(d3.select(this));
return pl + ' ' + pl;
})
.attr('stroke-dashoffset', function(d) {
return pathLen(d3.select(this));
});
// update
pathEnter.merge(path)
.attr('d', line)
.transition()
.attr('stroke-dasharray', function(d) {
var pl = pathLen(d3.select(this));
return pl + ' ' + pl;
})
.duration(2000)
.attr('stroke-dashoffset', 0);
// remove elements
path.exit().remove();
}
function addArea(g, data, cb, cName) {
// Update the area path.
var area = g.select('g.' + cName).selectAll('path.' + cName)
.data([data]);
var areaEnter = area.enter()
.append('svg:path')
.attr('class', cName);
areaEnter.merge(area)
.attr('d', cb);
// remove elements
area.exit().remove();
}
// Update the area path and lines.
function addAreaLines(g, data) {
// Update the area path.
addArea(g, data, area0.y0(yScale0.range()[0]), 'area0');
// Update the line path.
addLine(g, data, line0, 'line0');
// Update the area path.
addArea(g, data, area1.y1(yScale1.range()[0]), 'area1');
// Update the line path.
addLine(g, data, line1, 'line1');
}
// Update chart points
function addPoints(g, data) {
var radius = data.length > 100 ? 1 : 2.5;
var points = g.select('g.points.y0').selectAll('circle.point').data(data);
var pointsEnter = points.enter()
.append('svg:circle')
.attr('r', radius)
.attr('class', 'point');
pointsEnter.merge(points)
.attr('cx', function(d) {
return (xScale(d[0]) + xScale.bandwidth() / 2);
})
.attr('cy', function(d) {
return yScale0(d[1]);
});
// remove elements
points.exit().remove();
if (!dualYaxis)
return;
points = g.select('g.points.y1').selectAll('circle.point').data(data);
pointsEnter = points.enter()
.append('svg:circle')
.attr('r', radius)
.attr('class', 'point');
pointsEnter.merge(points)
.attr('cx', function(d) {
return (xScale(d[0]) + xScale.bandwidth() / 2);
})
.attr('cy', function(d) {
return yScale1(d[2]);
});
// remove elements
points.exit().remove();
}
function addAxis(g, data) {
var xTicks = getXTicks(data);
var tickDistance = xTicks.length > 1 ? (xScale(xTicks[1]) - xScale(xTicks[0])) : innerW();
var labelW = tickDistance - padding;
// Update the x-axis.
g.select('.x.axis')
.attr('transform', 'translate(0,' + yScale0.range()[0] + ')')
.call(xAxis.tickValues(xTicks))
.selectAll(".tick text")
.call(truncate, labelW > 0 ? labelW : innerW());
// Update the y0-axis.
g.select('.y0.axis')
.call(yAxis0.tickValues(getYTicks(yScale0)));
if (!dualYaxis)
return;
// Update the y1-axis.
g.select('.y1.axis')
.attr('transform', 'translate(' + innerW() + ', 0)')
.call(yAxis1.tickValues(getYTicks(yScale1)));
}
// Update the X-Y grid.
function addGrid(g, data) {
g.select('.x.grid')
.attr('transform', 'translate(0,' + yScale0.range()[0] + ')')
.call(xGrid
.tickValues(getXTicks(data))
.tickSize(-innerH(), 0, 0)
.tickSizeOuter(0)
.tickFormat('')
);
g.select('.y.grid')
.call(yGrid
.tickValues(getYTicks(yScale0))
.tickSize(-innerW(), 0)
.tickSizeOuter(0)
.tickFormat('')
);
}
function formatTooltip(data) {
var d = data.slice(0);
d[0] = (format.x) ? GoAccess.Util.fmtValue(d[0], format.x) : d[0];
d[1] = (format.y0) ? GoAccess.Util.fmtValue(d[1], format.y0) : d3.format(',')(d[1]);
dualYaxis && (d[2] = (format.y1) ? GoAccess.Util.fmtValue(d[2], format.y1) : d3.format(',')(d[2]));
var template = d3.select('#tpl-chart-tooltip').html();
return Hogan.compile(template).render({
'data': d
});
}
function mouseover(event, selection, data) {
var tooltip = selection.select('.chart-tooltip-wrap');
tooltip.html(formatTooltip(data))
.style('left', X(data) + 'px')
.style('top', (d3.pointer(event)[1] + 10) + 'px')
.style('display', 'block');
selection.select('line.indicator')
.style('display', 'block')
.attr('transform', 'translate(' + X(data) + ',' + 0 + ')');
}
function mouseout(selection, g) {
var tooltip = selection.select('.chart-tooltip-wrap');
tooltip.style('display', 'none');
g.select('line.indicator').style('display', 'none');
}
function addRects(selection, g, data) {
var w = (innerW() / data.length);
var rects = g.select('g.rects').selectAll('rect').data(data);
var rectsEnter = rects.enter()
.append('svg:rect')
.attr('height', innerH())
.attr('class', 'point');
rectsEnter.merge(rects)
.attr('width', w)
.attr('x', function(d, i) {
return (w * i);
})
.attr('y', 0)
.on('mousemove', function(event) {
mouseover(event, selection, d3.select(this).datum());
})
.on('mouseleave', function(event) {
mouseout(selection, g);
});
// remove elements
rects.exit().remove();
}
function chart(selection) {
selection.each(function (data) {
// normalize data
data = mapData(data);
// updates X-Y scales
updateScales(data);
// select the SVG element, if it exists
let svg = d3.select(this).select('svg');
// if the SVG element doesn't exist, create it
if (svg.empty()) {
svg = d3.select(this).append('svg').attr('width', width).attr('height', height);
createSkeleton(svg);
}
// Update the inner dimensions.
var g = svg.select('g')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
// Add grid
addGrid(g, data);
// Add chart lines and areas
addAreaLines(g, data);
// Add chart points
addPoints(g, data);
// Add axis
addAxis(g, data);
// Add rects
addRects(selection, g, data);
});
}
chart.opts = function (_) {
if (!arguments.length) return opts;
opts = _;
return chart;
};
chart.format = function (_) {
if (!arguments.length) return format;
format = _;
return chart;
};
chart.labels = function (_) {
if (!arguments.length) return labels;
labels = _;
return chart;
};
chart.margin = function (_) {
if (!arguments.length) return margin;
margin = _;
return chart;
};
chart.width = function (_) {
if (!arguments.length) return width;
width = _;
return chart;
};
chart.height = function (_) {
if (!arguments.length) return height;
height = _;
return chart;
};
chart.x = function (_) {
if (!arguments.length) return xValue;
xValue = _;
return chart;
};
chart.y0 = function (_) {
if (!arguments.length) return yValue0;
yValue0 = _;
return chart;
};
chart.y1 = function (_) {
if (!arguments.length) return yValue1;
yValue1 = _;
return chart;
};
return chart;
}
function BarChart(dualYaxis) {
var opts = {};
var margin = {
top : 20,
right : 50,
bottom : 40,
left : 50,
},
height = 170,
nTicks = 10,
padding = 10,
width = 760;
var labels = { x: 'Unnamed', y0: 'Unnamed', y1: 'Unnamed' };
var format = { x: null, y0: null, y1: null};
var xValue = function (d) {
return d[0];
},
yValue0 = function (d) {
return d[1];
},
yValue1 = function (d) {
return d[2];
};
var xScale = d3.scaleBand()
.paddingInner(0.1)
.paddingOuter(0.1);
var yScale0 = d3.scaleLinear().nice();
var yScale1 = d3.scaleLinear().nice();
var xAxis = d3.axisBottom(xScale)
.tickFormat(function (d) {
if (format.x)
return GoAccess.Util.fmtValue(d, format.x);
return d;
});
var yAxis0 = d3.axisLeft(yScale0)
.tickFormat(function (d) {
return d3.format('.2s')(d);
});
var yAxis1 = d3.axisRight(yScale1)
.tickFormat(function (d) {
if (format.y1)
return GoAccess.Util.fmtValue(d, format.y1);
return d3.format('.2s')(d);
});
var xGrid = d3.axisBottom(xScale);
var yGrid = d3.axisLeft(yScale0);
function innerW() {
return width - margin.left - margin.right;
}
function innerH() {
return height - margin.top - margin.bottom;
}
function getXTicks(data) {
const domain = xScale.domain();
if (data.length < nTicks)
return domain;
return d3.range(0, nTicks).map(function(i) {
const index = Math.floor(i * (domain.length - 1) / (nTicks - 1));
if (index >= 0 && index < domain.length)
return domain[index];
return null;
});
}
function getYTicks(scale) {
var domain = scale.domain();
return d3.range(domain[0], domain[1], Math.ceil(domain[1] / nTicks));
}
// The x-accessor for the path generator; xScale - xValue.
function X(d) {
return (xScale(d[0]) + xScale.bandwidth() / 2);
}
// Convert data to standard representation greedily;
// this is needed for nondeterministic accessors.
function mapData(data) {
var _datum = function (d, i) {
var datum = [xValue.call(data, d, i), yValue0.call(data, d, i)];
dualYaxis && datum.push(yValue1.call(data, d, i));
return datum;
};
return data.map(function (d, i) {
return _datum(d, i);
});
}
function updateScales(data) {
// Update the x-scale.
xScale.domain(data.map(function (d) {
return d[0];
}))
.range([0, innerW()]);
// Update the y-scale.
yScale0.domain([0, d3.max(data, function (d) {
return d[1];
})])
.range([innerH(), 0]);
// Update the y-scale.
// If all values are [0, 0]. This can cause issues when drawing the
// chart because all values passed to the scale will be mapped to the
// same value in the range, thus + 0.1 e.g., Not Found visitors.
dualYaxis && yScale1.domain([0, d3.max(data, function (d) {
return d[2];
}) + 0.1])
.range([innerH(), 0]);
}
function toggleOpacity(ele, op) {
d3.select(ele.parentNode).selectAll('.' + (ele.getAttribute('data-yaxis') == 'y0' ? 'y1' : 'y0')).attr('style', op);
}
function setLegendLabels(svg) {
// Legend Color
var rect = svg.selectAll('rect.legend.y0').data([null]);
var rectEnter = rect.enter()
.append('rect')
.attr('class', 'legend y0')
.attr('data-yaxis', 'y0')
.on('mousemove', function(d, i) {
toggleOpacity(this, 'opacity:0.1');
})
.on('mouseleave', function(d, i) {
toggleOpacity(this, null);
})
.attr('y', (height - 15));
rectEnter.merge(rect)
.attr('x', (width / 2) - 100);
// Legend Labels
var text = svg.selectAll('text.legend.y0').data([null]);
var textEnter = text.enter()
.append('text')
.attr('class', 'legend y0')
.attr('data-yaxis', 'y0')
.on('mousemove', function(d, i) {
toggleOpacity(this, 'opacity:0.1');
})
.on('mouseleave', function(d, i) {
toggleOpacity(this, null);
})
.attr('y', (height - 6));
textEnter.merge(text)
.attr('x', (width / 2) - 85)
.text(labels.y0);
if (!dualYaxis)
return;
// Legend Labels
rect = svg.selectAll('rect.legend.y1').data([null]);
var rectEnter = rect.enter()
.append('rect')
.attr('class', 'legend y1')
.attr('data-yaxis', 'y1')
.on('mousemove', function(d, i) {
toggleOpacity(this, 'opacity:0.1');
})
.on('mouseleave', function(d, i) {
toggleOpacity(this, null);
})
.attr('y', (height - 15));
rectEnter.merge(rect)
.attr('x', (width / 2));
// Legend Labels
text = svg.selectAll('text.legend.y1').data([null]);
var textEnter = text.enter()
.append('text')
.attr('class', 'legend y1')
.attr('data-yaxis', 'y1')
.on('mousemove', function(d, i) {
toggleOpacity(this, 'opacity:0.1');
})
.on('mouseleave', function(d, i) {
toggleOpacity(this, null);
})
.attr('y', (height - 6));
textEnter.merge(text)
.attr('x', (width / 2) + 15)
.text(labels.y1);
}
function setAxisLabels(svg) {
// Labels
svg.selectAll('text.axis-label.y0')
.data([null])
.enter()
.append('text')
.attr('class', 'axis-label y0')
.attr('y', 10)
.attr('x', 53)
.text(labels.y0);
if (!dualYaxis) return;
// Labels
var tEnter = svg.selectAll('text.axis-label.y1')
.data([null])
.enter()
.append('text')
.attr('class', 'axis-label y1')
.attr('y', 10)
.text(labels.y1);
dualYaxis && tEnter.attr('x', width - 25);
}
function createSkeleton(svg) {
const g = svg.append('g');
// Grid
g.append('g')
.attr('class', 'x grid');
g.append('g')
.attr('class', 'y grid');
// Axis
g.append('g')
.attr('class', 'x axis');
g.append('g')
.attr('class', 'y0 axis');
dualYaxis && g.append('g')
.attr('class', 'y1 axis');
// Bars
g.append('g')
.attr('class', 'bars y0');
dualYaxis && g.append('g')
.attr('class', 'bars y1');
// Rects
g.append('g')
.attr('class', 'rects');
setAxisLabels(svg);
setLegendLabels(svg);
// Mouseover line
g.append('line')
.attr('y2', innerH())
.attr('y1', 0)
.attr('class', 'indicator');
}
// Update the area path and lines.
function addBars(g, data) {
var bars = g.select('g.bars.y0').selectAll('rect.bar').data(data);
// enter
var enter = bars.enter()
.append('svg:rect')
.attr('class', 'bar')
.attr('height', 0)
.attr('width', function (d, i) { return xScale.bandwidth() / 2; })
.attr('x', function (d, i) { return xScale(d[0]); })
.attr('y', function (d, i) { return innerH(); });
// update
bars.merge(enter)
.attr('width', xScale.bandwidth() / 2)
.attr('x', function (d) { return xScale(d[0]); })
.transition()
.delay(function (d, i) { return i / data.length * 1000; })
.duration(500)
.attr('height', function (d, i) { return innerH() - yScale0(d[1]); })
.attr('y', function (d, i) { return yScale0(d[1]); });
// remove elements
bars.exit().remove();
if (!dualYaxis)
return;
bars = g.select('g.bars.y1').selectAll('rect.bar').data(data);
// enter
enter = bars.enter()
.append('svg:rect')
.attr('class', 'bar')
.attr('height', 0)
.attr('width', function (d, i) { return xScale.bandwidth() / 2; })
.attr('x', function (d) { return (xScale(d[0]) + xScale.bandwidth() / 2); })
.attr('y', function (d, i) { return innerH(); });
// update
bars.merge(enter)
.attr('width', xScale.bandwidth() / 2)
.attr('x', function (d) { return (xScale(d[0]) + xScale.bandwidth() / 2); })
.transition()
.delay(function (d, i) { return i / data.length * 1000; })
.duration(500)
.attr('height', function (d, i) { return innerH() - yScale1(d[2]); })
.attr('y', function (d, i) { return yScale1(d[2]); });
// remove elements
bars.exit().remove();
}
function addAxis(g, data) {
var xTicks = getXTicks(data);
var tickDistance = xTicks.length > 1 ? (xScale(xTicks[1]) - xScale(xTicks[0])) : innerW();
var labelW = tickDistance - padding;
// Update the x-axis.
g.select('.x.axis')
.attr('transform', 'translate(0,' + yScale0.range()[0] + ')')
.call(xAxis.tickValues(xTicks))
.selectAll(".tick text")
.call(truncate, labelW > 0 ? labelW : innerW());
// Update the y0-axis.
g.select('.y0.axis')
.call(yAxis0.tickValues(getYTicks(yScale0)));
if (!dualYaxis)
return;
// Update the y1-axis.
g.select('.y1.axis')
.attr('transform', 'translate(' + innerW() + ', 0)')
.call(yAxis1.tickValues(getYTicks(yScale1)));
}
// Update the X-Y grid.
function addGrid(g, data) {
g.select('.x.grid')
.attr('transform', 'translate(0,' + yScale0.range()[0] + ')')
.call(xGrid
.tickValues(getXTicks(data))
.tickSize(-innerH(), 0, 0)
.tickSizeOuter(0)
.tickFormat('')
);
g.select('.y.grid')
.call(yGrid
.tickValues(getYTicks(yScale0))
.tickSize(-innerW(), 0)
.tickSizeOuter(0)
.tickFormat('')
);
}
function formatTooltip(data) {
var d = data.slice(0);
d[0] = (format.x) ? GoAccess.Util.fmtValue(d[0], format.x) : d[0];
d[1] = (format.y0) ? GoAccess.Util.fmtValue(d[1], format.y0) : d3.format(',')(d[1]);
dualYaxis && (d[2] = (format.y1) ? GoAccess.Util.fmtValue(d[2], format.y1) : d3.format(',')(d[2]));
var template = d3.select('#tpl-chart-tooltip').html();
return Hogan.compile(template).render({
'data': d
});
}
function mouseover(event, selection, data) {
var tooltip = selection.select('.chart-tooltip-wrap');
tooltip.html(formatTooltip(data))
.style('left', X(data) + 'px')
.style('top', (d3.pointer(event)[1] + 10) + 'px')
.style('display', 'block');
selection.select('line.indicator')
.style('display', 'block')
.attr('transform', 'translate(' + X(data) + ',' + 0 + ')');
}
function mouseout(selection, g) {
var tooltip = selection.select('.chart-tooltip-wrap');
tooltip.style('display', 'none');
g.select('line.indicator').style('display', 'none');
}
function addRects(selection, g, data) {
var w = (innerW() / data.length);
var rects = g.select('g.rects').selectAll('rect').data(data);
var rectsEnter = rects.enter()
.append('svg:rect')
.attr('height', innerH())
.attr('class', 'point');
rectsEnter.merge(rects)
.attr('width', w)
.attr('x', function(d, i) {
return (w * i);
})
.attr('y', 0)
.on('mousemove', function(event) {
mouseover(event, selection, d3.select(this).datum());
})
.on('mouseleave', function(event) {
mouseout(selection, g);
});
// remove elements
rects.exit().remove();
}
function chart(selection) {
selection.each(function (data) {
// normalize data
data = mapData(data);
// updates X-Y scales
updateScales(data);
// select the SVG element, if it exists
let svg = d3.select(this).select('svg');
// if the SVG element doesn't exist, create it
if (svg.empty()) {
svg = d3.select(this).append('svg').attr('width', width).attr('height', height);
createSkeleton(svg);
}
// Update the inner dimensions.
var g = svg.select('g')
.attr('transform', 'translate(' + margin.left + ',' + margin.top + ')');
// Add grid
addGrid(g, data);
// Add axis
addAxis(g, data);
// Add chart lines and areas
addBars(g, data);
// Add rects
addRects(selection, g, data);
});
}
chart.opts = function (_) {
if (!arguments.length) return opts;
opts = _;
return chart;
};
chart.format = function (_) {
if (!arguments.length) return format;
format = _;
return chart;
};
chart.labels = function (_) {
if (!arguments.length) return labels;
labels = _;
return chart;
};
chart.width = function (_) {
if (!arguments.length) return width;
width = _;
return chart;
};
chart.height = function (_) {
if (!arguments.length) return height;
height = _;
return chart;
};
chart.x = function (_) {
if (!arguments.length) return xValue;
xValue = _;
return chart;
};
chart.y0 = function (_) {
if (!arguments.length) return yValue0;
yValue0 = _;
return chart;
};
chart.y1 = function (_) {
if (!arguments.length) return yValue1;
yValue1 = _;
return chart;
};
return chart;
}