Skip to main content
Module

x/billboardjs/ChartInternal/internals/legend.ts

πŸ“Š Re-usable, easy interface JavaScript chart library based on D3.js
Go to Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748
/** * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license */import { select as d3Select, namespaces as d3Namespaces} from "d3-selection";import {document} from "../../module/browser";import {$FOCUS, $GAUGE, $LEGEND} from "../../config/classes";import {KEY} from "../../module/Cache";import {callFn, isDefined, getOption, isEmpty, isFunction, notEmpty, tplProcess} from "../../module/util";
export default { /** * Initialize the legend. * @private */ initLegend(): void { const $$ = this; const {config, $el} = $$;
$$.legendItemTextBox = {}; $$.state.legendHasRendered = false;
if (config.legend_show) { if (!config.legend_contents_bindto) { $el.legend = $$.$el.svg.append("g") .classed($LEGEND.legend, true) .attr("transform", $$.getTranslate("legend")); }
// MEMO: call here to update legend box and translate for all // MEMO: translate will be updated by this, so transform not needed in updateLegend() $$.updateLegend(); } else { $$.state.hiddenLegendIds = $$.mapToIds($$.data.targets); } },
/** * Update legend element * @param {Array} targetIds ID's of target * @param {object} options withTransform : Whether to use the transform property / withTransitionForTransform: Whether transition is used when using the transform property / withTransition : whether or not to transition. * @param {object} transitions Return value of the generateTransitions * @private */ updateLegend(targetIds, options, transitions): void { const $$ = this; const {config, state, scale, $el} = $$; const optionz = options || { withTransform: false, withTransitionForTransform: false, withTransition: false };
optionz.withTransition = getOption(optionz, "withTransition", true); optionz.withTransitionForTransform = getOption(optionz, "withTransitionForTransform", true);
if (config.legend_contents_bindto && config.legend_contents_template) { $$.updateLegendTemplate(); } else { $$.updateLegendElement( targetIds || $$.mapToIds($$.data.targets), optionz, transitions ); }
// toggle legend state $el.legend.selectAll(`.${$LEGEND.legendItem}`) .classed($LEGEND.legendItemHidden, function(id) { const hide = !$$.isTargetToShow(id);
if (hide) { this.style.opacity = null; }
return hide; });
// Update size and scale $$.updateScales(false, !scale.zoom); $$.updateSvgSize();
// Update g positions $$.transformAll(optionz.withTransitionForTransform, transitions);
state.legendHasRendered = true; },
/** * Update legend using template option * @private */ updateLegendTemplate(): void { const $$ = this; const {config, $el} = $$; const wrapper = d3Select(config.legend_contents_bindto); const template = config.legend_contents_template;
if (!wrapper.empty()) { const targets = $$.mapToIds($$.data.targets); const ids: any[] = []; let html = "";
targets.forEach(v => { const content = isFunction(template) ? template.bind($$.api)(v, $$.color(v), $$.api.data(v)[0].values) : tplProcess(template, { COLOR: $$.color(v), TITLE: v });
if (content) { ids.push(v); html += content; } });
const legendItem = wrapper.html(html) .selectAll(function() { return this.childNodes; }) .data(ids);
$$.setLegendItem(legendItem);
$el.legend = wrapper; } },
/** * Update the size of the legend. * @param {Obejct} size Size object * @private */ updateSizeForLegend(size): void { const $$ = this; const {config, state: { isLegendTop, isLegendLeft, isLegendRight, isLegendInset, current }} = $$; const {width, height} = size;
const insetLegendPosition = { top: isLegendTop ? $$.getCurrentPaddingTop() + config.legend_inset_y + 5.5 : current.height - height - $$.getCurrentPaddingBottom() - config.legend_inset_y, left: isLegendLeft ? $$.getCurrentPaddingLeft() + config.legend_inset_x + 0.5 : current.width - width - $$.getCurrentPaddingRight() - config.legend_inset_x + 0.5 };
$$.state.margin3 = { top: isLegendRight ? 0 : isLegendInset ? insetLegendPosition.top : current.height - height, right: NaN, bottom: 0, left: isLegendRight ? current.width - width : isLegendInset ? insetLegendPosition.left : 0 }; },
/** * Transform Legend * @param {boolean} withTransition whether or not to transition. * @private */ transformLegend(withTransition): void { const $$ = this; const {$el: {legend}, $T} = $$;
$T(legend, withTransition) .attr("transform", $$.getTranslate("legend")); },
/** * Update the legend step * @param {number} step Step value * @private */ updateLegendStep(step: number): void { this.state.legendStep = step; },
/** * Update legend item width * @param {number} width Width value * @private */ updateLegendItemWidth(width: number): void { this.state.legendItemWidth = width; },
/** * Update legend item height * @param {number} height Height value * @private */ updateLegendItemHeight(height): void { this.state.legendItemHeight = height; },
/** * Update legend item color * @param {string} id Corresponding data ID value * @param {string} color Color value * @private */ updateLegendItemColor(id: string, color: string): void { const {legend} = this.$el;
if (legend) { legend.select(`.${$LEGEND.legendItem}-${id} line`) .style("stroke", color); } },
/** * Get the width of the legend * @returns {number} width * @private */ getLegendWidth(): number { const $$ = this; const {current: {width}, isLegendRight, isLegendInset, legendItemWidth, legendStep} = $$.state;
return $$.config.legend_show ? ( isLegendRight || isLegendInset ? legendItemWidth * (legendStep + 1) : width ) : 0; },
/** * Get the height of the legend * @returns {number} height * @private */ getLegendHeight(): number { const $$ = this; const {current, isLegendRight, legendItemHeight, legendStep} = $$.state;
return $$.config.legend_show ? ( isLegendRight ? current.height : Math.max(20, legendItemHeight) * (legendStep + 1) ) : 0; },
/** * Get the opacity of the legend that is unfocused * @param {d3.selection} legendItem Legend item node * @returns {string|null} opacity * @private */ opacityForUnfocusedLegend(legendItem): string | null { return legendItem.classed($LEGEND.legendItemHidden) ? null : "0.3"; },
/** * Toggles the focus of the legend * @param {Array} targetIds ID's of target * @param {boolean} focus whether or not to focus. * @private */ toggleFocusLegend(targetIds: string[], focus: boolean): void { const $$ = this; const {$el: {legend}, $T} = $$; const targetIdz = $$.mapToTargetIds(targetIds);
legend && $T(legend.selectAll(`.${$LEGEND.legendItem}`) .filter(id => targetIdz.indexOf(id) >= 0) .classed($FOCUS.legendItemFocused, focus)) .style("opacity", function() { return focus ? null : $$.opacityForUnfocusedLegend.call($$, d3Select(this)); }); },
/** * Revert the legend to its default state * @private */ revertLegend(): void { const $$ = this; const {$el: {legend}, $T} = $$;
legend && $T(legend.selectAll(`.${$LEGEND.legendItem}`) .classed($FOCUS.legendItemFocused, false)) .style("opacity", null); },
/** * Shows the legend * @param {Array} targetIds ID's of target * @private */ showLegend(targetIds: string[]): void { const $$ = this; const {config, $el, $T} = $$;
if (!config.legend_show) { config.legend_show = true;
$el.legend ? $el.legend.style("visibility", null) : $$.initLegend();
!$$.state.legendHasRendered && $$.updateLegend(); }
$$.removeHiddenLegendIds(targetIds);
$T( $el.legend.selectAll($$.selectorLegends(targetIds)) .style("visibility", null) ).style("opacity", null); },
/** * Hide the legend * @param {Array} targetIds ID's of target * @private */ hideLegend(targetIds: string[]): void { const $$ = this; const {config, $el: {legend}} = $$;
if (config.legend_show && isEmpty(targetIds)) { config.legend_show = false; legend.style("visibility", "hidden"); }
$$.addHiddenLegendIds(targetIds); legend.selectAll($$.selectorLegends(targetIds)) .style("opacity", "0") .style("visibility", "hidden"); },
/** * Get legend item textbox dimension * @param {string} id Data ID * @param {HTMLElement|d3.selection} textElement Text node element * @returns {object} Bounding rect * @private */ getLegendItemTextBox(id?: string, textElement?) { const $$ = this; const {cache, state} = $$; let data;
// do not prefix w/'$', to not be resetted cache in .load() call const cacheKey = KEY.legendItemTextBox;
if (id) { data = (!state.redrawing && cache.get(cacheKey)) || {};
if (!data[id]) { data[id] = $$.getTextRect(textElement, $LEGEND.legendItem); cache.add(cacheKey, data); }
data = data[id]; }
return data; },
/** * Set legend item style & bind events * @param {d3.selection} item Item node * @private */ setLegendItem(item): void { const $$ = this; const {$el, api, config, state} = $$; const isTouch = state.inputType === "touch"; const hasGauge = $$.hasType("gauge"); const useCssRule = config.boost_useCssRule;
item .attr("class", function(id) { const node = d3Select(this); const itemClass = (!node.empty() && node.attr("class")) || "";
return itemClass + $$.generateClass($LEGEND.legendItem, id); }) .style("visibility", id => ($$.isLegendToShow(id) ? null : "hidden"));
if (config.interaction_enabled) { if (useCssRule) { [ [`.${$LEGEND.legendItem}`, "cursor:pointer"], [`.${$LEGEND.legendItem} text`, "pointer-events:none"], [`.${$LEGEND.legendItemPoint} text`, "pointer-events:none"], [`.${$LEGEND.legendItemTile}`, "pointer-events:none"], [`.${$LEGEND.legendItemEvent}`, "fill-opacity:0"] ].forEach(v => { const [selector, props] = v;
$$.setCssRule(false, selector, [props])($el.legend); }); }
item .style("cursor", $$.getStylePropValue("pointer")) .on("click", function(event, id) { if (!callFn(config.legend_item_onclick, api, id)) { if (event.altKey) { api.hide(); api.show(id); } else { api.toggle(id);
d3Select(this) .classed($FOCUS.legendItemFocused, false); } }
isTouch && $$.hideTooltip(); });
!isTouch && item .on("mouseout", function(event, id) { if (!callFn(config.legend_item_onout, api, id)) { d3Select(this).classed($FOCUS.legendItemFocused, false);
if (hasGauge) { $$.undoMarkOverlapped($$, `.${$GAUGE.gaugeValue}`); }
$$.api.revert(); } }) .on("mouseover", function(event, id) { if (!callFn(config.legend_item_onover, api, id)) { d3Select(this).classed($FOCUS.legendItemFocused, true);
if (hasGauge) { $$.markOverlapped(id, $$, `.${$GAUGE.gaugeValue}`); }
if (!state.transiting && $$.isTargetToShow(id)) { api.focus(id); } } }); } },
/** * Update the legend * @param {Array} targetIds ID's of target * @param {object} options withTransform : Whether to use the transform property / withTransitionForTransform: Whether transition is used when using the transform property / withTransition : whether or not to transition. * @private */ updateLegendElement(targetIds: string[], options): void { const $$ = this; const {config, state, $el: {legend}, $T} = $$; const paddingTop = 4; const paddingRight = 10; const posMin = 10; const tileWidth = config.legend_item_tile_width + 5; let maxWidth = 0; let maxHeight = 0; let xForLegend; let yForLegend; let totalLength = 0; const offsets = {}; const widths = {}; const heights = {}; const margins = [0]; const steps = {}; let step = 0; let background; const isLegendRightOrInset = state.isLegendRight || state.isLegendInset;
// Skip elements when their name is set to null const targetIdz = targetIds .filter(id => !isDefined(config.data_names[id]) || config.data_names[id] !== null);
const withTransition = options.withTransition;
const updatePositions = function(textElement, id, index) { const reset = index === 0; const isLast = index === targetIdz.length - 1; const box = $$.getLegendItemTextBox(id, textElement);
const itemWidth = box.width + tileWidth + (isLast && !isLegendRightOrInset ? 0 : paddingRight) + config.legend_padding; const itemHeight = box.height + paddingTop; const itemLength = isLegendRightOrInset ? itemHeight : itemWidth; const areaLength = isLegendRightOrInset ? $$.getLegendHeight() : $$.getLegendWidth(); let margin;
// MEMO: care about condifion of step, totalLength const updateValues = function(id2, withoutStep?: boolean) { if (!withoutStep) { margin = (areaLength - totalLength - itemLength) / 2;
if (margin < posMin) { margin = (areaLength - itemLength) / 2; totalLength = 0; step++; } }
steps[id2] = step; margins[step] = state.isLegendInset ? 10 : margin; offsets[id2] = totalLength; totalLength += itemLength; };
if (reset) { totalLength = 0; step = 0; maxWidth = 0; maxHeight = 0; }
if (config.legend_show && !$$.isLegendToShow(id)) { widths[id] = 0; heights[id] = 0; steps[id] = 0; offsets[id] = 0;
return; }
widths[id] = itemWidth; heights[id] = itemHeight;
if (!maxWidth || itemWidth >= maxWidth) { maxWidth = itemWidth; }
if (!maxHeight || itemHeight >= maxHeight) { maxHeight = itemHeight; }
const maxLength = isLegendRightOrInset ? maxHeight : maxWidth;
if (config.legend_equally) { Object.keys(widths).forEach(id2 => (widths[id2] = maxWidth)); Object.keys(heights).forEach(id2 => (heights[id2] = maxHeight)); margin = (areaLength - maxLength * targetIdz.length) / 2;
if (margin < posMin) { totalLength = 0; step = 0; targetIdz.forEach(id2 => updateValues(id2)); } else { updateValues(id, true); } } else { updateValues(id); } };
if (state.isLegendInset) { step = config.legend_inset_step ? config.legend_inset_step : targetIdz.length; $$.updateLegendStep(step); }
if (state.isLegendRight) { xForLegend = id => maxWidth * steps[id]; yForLegend = id => margins[steps[id]] + offsets[id]; } else if (state.isLegendInset) { xForLegend = id => maxWidth * steps[id] + 10; yForLegend = id => margins[steps[id]] + offsets[id]; } else { xForLegend = id => margins[steps[id]] + offsets[id]; yForLegend = id => maxHeight * steps[id]; }
const xForLegendText = (id, i?: number) => xForLegend(id, i) + 4 + config.legend_item_tile_width; const xForLegendRect = (id, i?: number) => xForLegend(id, i); const x1ForLegendTile = (id, i?: number) => xForLegend(id, i) - 2; const x2ForLegendTile = (id, i?: number) => xForLegend(id, i) - 2 + config.legend_item_tile_width;
const yForLegendText = (id, i?: number) => yForLegend(id, i) + 9; const yForLegendRect = (id, i?: number) => yForLegend(id, i) - 5; const yForLegendTile = (id, i?: number) => yForLegend(id, i) + 4;
const pos = -200;
// Define g for legend area const l = legend.selectAll(`.${$LEGEND.legendItem}`) .data(targetIdz) .enter() .append("g");
$$.setLegendItem(l);
l.append("text") .text(id => (isDefined(config.data_names[id]) ? config.data_names[id] : id)) .each(function(id, i) { updatePositions(this, id, i); }) .style("pointer-events", $$.getStylePropValue("none")) .attr("x", isLegendRightOrInset ? xForLegendText : pos) .attr("y", isLegendRightOrInset ? pos : yForLegendText);
l.append("rect") .attr("class", $LEGEND.legendItemEvent) .style("fill-opacity", $$.getStylePropValue("0")) .attr("x", isLegendRightOrInset ? xForLegendRect : pos) .attr("y", isLegendRightOrInset ? pos : yForLegendRect);
const getColor = id => { const data = $$.getDataById(id);
return $$.levelColor ? $$.levelColor(data.values[0].value) : $$.color(data); };
const usePoint = config.legend_usePoint;
if (usePoint) { const ids: any[] = [];
l.append(d => { const pattern = notEmpty(config.point_pattern) ? config.point_pattern : [config.point_type];
ids.indexOf(d) === -1 && ids.push(d);
let point = pattern[ids.indexOf(d) % pattern.length];
if (point === "rectangle") { point = "rect"; }
return document.createElementNS(d3Namespaces.svg, ("hasValidPointType" in $$) && $$.hasValidPointType(point) ? point : "use"); }) .attr("class", $LEGEND.legendItemPoint) .style("fill", getColor) .style("pointer-events", $$.getStylePropValue("none")) .attr("href", (data, idx, selection) => { const node = selection[idx]; const nodeName = node.nodeName.toLowerCase(); const id = $$.getTargetSelectorSuffix(data);
return nodeName === "use" ? `#${state.datetimeId}-point${id}` : undefined; }); } else { l.append("line") .attr("class", $LEGEND.legendItemTile) .style("stroke", getColor) .style("pointer-events", $$.getStylePropValue("none")) .attr("x1", isLegendRightOrInset ? x1ForLegendTile : pos) .attr("y1", isLegendRightOrInset ? pos : yForLegendTile) .attr("x2", isLegendRightOrInset ? x2ForLegendTile : pos) .attr("y2", isLegendRightOrInset ? pos : yForLegendTile) .attr("stroke-width", config.legend_item_tile_height); }
// Set background for inset legend background = legend.select(`.${$LEGEND.legendBackground} rect`);
if (state.isLegendInset && maxWidth > 0 && background.size() === 0) { background = legend.insert("g", `.${$LEGEND.legendItem}`) .attr("class", $LEGEND.legendBackground) .append("rect"); }
const texts = legend.selectAll("text") .data(targetIdz) .text(id => (isDefined(config.data_names[id]) ? config.data_names[id] : id)) // MEMO: needed for update .each(function(id, i) { updatePositions(this, id, i); });
$T(texts, withTransition) .attr("x", xForLegendText) .attr("y", yForLegendText);
const rects = legend.selectAll(`rect.${$LEGEND.legendItemEvent}`) .data(targetIdz);
$T(rects, withTransition) .attr("width", id => widths[id]) .attr("height", id => heights[id]) .attr("x", xForLegendRect) .attr("y", yForLegendRect);
if (usePoint) { const tiles = legend.selectAll(`.${$LEGEND.legendItemPoint}`) .data(targetIdz);
$T(tiles, withTransition) .each(function() { const nodeName = this.nodeName.toLowerCase(); const pointR = config.point_r; let x = "x"; let y = "y"; let xOffset = 2; let yOffset = 2.5; let radius; let width; let height;
if (nodeName === "circle") { const size = pointR * 0.2;
x = "cx"; y = "cy"; radius = pointR + size; xOffset = pointR * 2; yOffset = -size; } else if (nodeName === "rect") { const size = pointR * 2.5;
width = size; height = size; yOffset = 3; }
d3Select(this) .attr(x, d => x1ForLegendTile(d) + xOffset) .attr(y, d => yForLegendTile(d) - yOffset) .attr("r", radius) .attr("width", width) .attr("height", height); }); } else { const tiles = legend.selectAll(`line.${$LEGEND.legendItemTile}`) .data(targetIdz);
$T(tiles, withTransition) .style("stroke", getColor) .attr("x1", x1ForLegendTile) .attr("y1", yForLegendTile) .attr("x2", x2ForLegendTile) .attr("y2", yForLegendTile); }
if (background) { $T(background, withTransition) .attr("height", $$.getLegendHeight() - 12) .attr("width", maxWidth * (step + 1) + 10); }
// Update all to reflect change of legend $$.updateLegendItemWidth(maxWidth); $$.updateLegendItemHeight(maxHeight); $$.updateLegendStep(step); }};