import * as d3Select from 'd3-selection'; import * as d3Transition from 'd3-transition'; import * as d3Interpolate from 'd3-interpolate'; import { ExternalAction, IConnectionCompanyData } from '../connection-map.component'; import { default as SubsidiaryList, ISubsidiaryCompanyData, ITreeFilterConfig, SG_CLASS, SG_CLASS_CIRCLE_G, SG_CLASS_SUBS_TEXT, SG_HEIGHT, SG_TEXT_SUBS_CLOSED, SG_TEXT_SUBS_OPEN } from './subsidiary-list-group.class'; import { generateDefs } from './defs.class'; import { patchMSEdge, translateString } from '../util/svg'; import has from 'lodash/has'; import isEqual from 'lodash/isEqual'; import last from 'lodash/last'; export const DCG_CLASS = 'display-company'; export default class ConnectionMap { EXTENT_MARGIN: number = 200; DOM_NODE: any; SVG: any; VIEW: any; DEFS: any; FULL_WIDTH: number; FULL_HEIGHT: number; VIEWPORT_EXTENT: [[number, number], [number, number]]; SUBSIDIARY_LISTS: Array = []; // Stack containing currently displayed sub-trees ON_CONNECTION_NAVIGATE: ExternalAction = null; ON_COMPANY_PAGE_NAVIGATE: ExternalAction = null; ON_COMPANY_VIEW: ExternalAction = null; ON_LATEST_NEWS_VIEW: ExternalAction = null; ON_ADD_TO_TARGET_LIST: ExternalAction = null; CURRENT_FILTERS: ITreeFilterConfig = { AND: [], OR: [] }; constructor(node: SVGSVGElement) { this.DEFS = generateDefs(node); patchMSEdge(node); const nodeDimensions = node.getBoundingClientRect(); this.DOM_NODE = d3Select.select(node); this.FULL_WIDTH = nodeDimensions.width; this.FULL_HEIGHT = nodeDimensions.height; this.SVG = d3Select.select(node); this.VIEW = this.SVG.append('g') .attr('class', 'connection-map__view') .attr('width', this.FULL_WIDTH) .attr('height', this.FULL_HEIGHT); this.VIEWPORT_EXTENT = [[0, 0], [this.FULL_WIDTH, this.FULL_HEIGHT]]; } updateNode(node: SVGSVGElement) { const nodeDimensions = node.getBoundingClientRect(); this.DOM_NODE = d3Select.select(node); this.FULL_WIDTH = nodeDimensions.width; this.FULL_HEIGHT = nodeDimensions.height; this.VIEW.attr('width', this.FULL_WIDTH).attr( 'height', this.FULL_HEIGHT ); this.VIEWPORT_EXTENT = [[0, 0], [this.FULL_WIDTH, this.FULL_HEIGHT]]; } bindConnectionMapNavigationEventToNodes = ( fn: (scoutId: string) => void ) => { this.ON_CONNECTION_NAVIGATE = fn; }; bindCompanyViewEventToNodes = (fn: (scoutId: string) => void) => { this.ON_COMPANY_VIEW = fn; }; bindCompanyPageNavigationEventToNodes = (fn: (scoutId: string) => void) => { this.ON_COMPANY_PAGE_NAVIGATE = fn; }; bindLatestNewsViewEventToNodes = (fn: (scoutId: string) => void) => { this.ON_LATEST_NEWS_VIEW = fn; }; bindAddToTargetListNavigationEventToNodes = ( fn: (scoutId: string) => void ) => { this.ON_ADD_TO_TARGET_LIST = fn; }; setFilter(propertyPath: Array, value: any, ORKey?: string) { if (!ORKey) { if (value === null) { this.CURRENT_FILTERS.AND = this.CURRENT_FILTERS.AND.filter( filter => !isEqual(filter.propertyPath, propertyPath) ); } else { this.CURRENT_FILTERS.AND.push({ propertyPath, value }); } } else { if (value === null) { this.CURRENT_FILTERS.OR = this.CURRENT_FILTERS.OR.filter( filter => { return !( isEqual(filter.propertyPath, propertyPath) && isEqual(filter.value, ORKey) ); } ); } else { this.CURRENT_FILTERS.OR.push({ propertyPath, value }); } } this.SUBSIDIARY_LISTS.forEach(subsidiaryList => { subsidiaryList.applyFilters(this.CURRENT_FILTERS); }); // Scroll to top left on every filter application d3Transition .transition() .duration(500) .tween('scrollOffset', () => { const el = document.querySelector( '.connection-map__map-scroll' ); const iScrollTop = d3Interpolate.interpolateNumber( el.scrollTop, 0 ); const iScrollLeft = d3Interpolate.interpolateNumber( el.scrollLeft, 0 ); return t => { el.scrollTop = iScrollTop(t); el.scrollLeft = iScrollLeft(t); }; }) .on('end', this._updateDimensions.bind(this)); } clearFilters() { this.CURRENT_FILTERS = { AND: [], OR: [] }; this.SUBSIDIARY_LISTS.forEach(subsidiaryList => { subsidiaryList.applyFilters(this.CURRENT_FILTERS); }); } private _updateDimensions() { const bb = this.VIEW.node().getBBox(); const parentDimensions = this.SVG.node().parentElement ? this.SVG.node().parentElement.getBoundingClientRect() : this.SVG.node().parentNode.getBoundingClientRect(); const width = Math.max( bb.width + this.EXTENT_MARGIN, parentDimensions.width ); const height = Math.max( bb.height + this.EXTENT_MARGIN, parentDimensions.height ); this.SVG.style('width', width); this.SVG.style('height', height); // HERE! } _onSubsidiaryListClick = ( clickData: ISubsidiaryCompanyData, clickIndex, clickElements: Array ) => { d3Select.event.stopPropagation(); d3Select.event.preventDefault(); const hasNonHiddenSubsidiariesProperty = has(clickData, [ 'nonHiddenSubsidiaries' ]); if ( (hasNonHiddenSubsidiariesProperty && clickData.nonHiddenSubsidiaries) || !hasNonHiddenSubsidiariesProperty ) { const activeState = clickData.active; const clickedElement = clickElements[clickIndex]; const clickedParent = clickedElement.parentElement ? clickedElement.parentElement.parentElement : (clickedElement.parentNode.parentNode as d3Select.BaseType); // TODO: Hacky.. // TODO: Hacky for IE11 - does Node have querySelector etc? // can use a d3 method to select this? // Algorithm to close all unneeded sub-trees // WHILE => deepest subTree currently displayed IS NOT EQUAL to parent tree of clicked button // DO => remove subtree // Result => All subtrees below parent of clicked button are removed while ( this.SUBSIDIARY_LISTS[ this.SUBSIDIARY_LISTS.length - 1 ].NODE.selectAll(`g.${SG_CLASS}`) .filter( (filterData, filterIndex, filterElements) => filterElements[filterIndex] === clickedParent ) .empty() ) { const popped = this.SUBSIDIARY_LISTS.pop(); popped.NODE.selectAll('g.subsidiary-company') .data() .forEach(d => (d.active = false)); // dirty mutate popped.PARENT.data()[0].active = false; //debugger popped.PARENT.selectAll(`.${SG_CLASS_CIRCLE_G} text`).text( SG_TEXT_SUBS_CLOSED ); popped.PARENT.selectAll(`.${SG_CLASS_SUBS_TEXT}`).style( 'visibility', '' ); popped.DATA = []; popped.render(true); popped.NODE.remove(); } // END subTree removal algorithm if (activeState) { // if open then we must close it clickData.active = false; d3Select .select(clickedElement) .selectAll('text') .text(SG_TEXT_SUBS_CLOSED); d3Select .select(clickedParent) .selectAll(`.${SG_CLASS_SUBS_TEXT}`) .style('visibility', ''); } else { // if closed we must open it clickData.active = true; const newSubsidiaryList = new SubsidiaryList( this.VIEW, d3Select.select(clickedParent), last(this.SUBSIDIARY_LISTS), clickData.subsidiaries, { ON_CONNECTION_NAVIGATE: this.ON_CONNECTION_NAVIGATE, ON_COMPANY_PAGE_NAVIGATE: this.ON_COMPANY_PAGE_NAVIGATE, ON_COMPANY_VIEW: this.ON_COMPANY_VIEW, ON_LATEST_NEWS_VIEW: this.ON_LATEST_NEWS_VIEW, ON_ADD_TO_TARGET_LIST: this.ON_ADD_TO_TARGET_LIST, ON_SUBSIDIARY_LIST_CLICK: this._onSubsidiaryListClick }, this.CURRENT_FILTERS ); this.SUBSIDIARY_LISTS.push(newSubsidiaryList); d3Select .select(clickedElement) .selectAll(`.${SG_CLASS_CIRCLE_G} text`) .text(SG_TEXT_SUBS_OPEN); d3Select .select(clickedParent) .selectAll(`.${SG_CLASS_SUBS_TEXT}`) .filter( (data, index, nodes) => (nodes[index] as HTMLElement).parentNode === clickedParent ) .style('visibility', 'hidden'); this._updateDimensions(); } } }; render(tree: IConnectionCompanyData) { if (!tree) throw new Error('Connection map render called without data'); const X = 0; const Y = 0; const initialSubsidiaryList = new SubsidiaryList( this.VIEW, this.VIEW.append('g') // root .attr('x', X) .attr('y', Y) .attr('transform', d => translateString(X, Y)) .attr('height', SG_HEIGHT) .attr('width', 0) .attr('class', DCG_CLASS) .data([tree]), null, tree.subsidiaries, { ON_CONNECTION_NAVIGATE: this.ON_CONNECTION_NAVIGATE, ON_COMPANY_PAGE_NAVIGATE: this.ON_COMPANY_PAGE_NAVIGATE, ON_COMPANY_VIEW: this.ON_COMPANY_VIEW, ON_LATEST_NEWS_VIEW: this.ON_LATEST_NEWS_VIEW, ON_ADD_TO_TARGET_LIST: this.ON_ADD_TO_TARGET_LIST, ON_SUBSIDIARY_LIST_CLICK: this._onSubsidiaryListClick }, this.CURRENT_FILTERS ); this.SUBSIDIARY_LISTS.push(initialSubsidiaryList); setTimeout(() => { this._updateDimensions(); }, 1000); } }