import cx from 'classnames'; import * as d3Select from 'd3-selection'; import * as d3Shape from 'd3-shape'; import get from 'lodash/get'; import cloneDeep from 'lodash/cloneDeep'; import has from 'lodash/has'; import { ExternalAction, IConnectionCompanyData } from '../connection-map.component'; import { COLOUR } from '../util/constants'; import { isMSEdge, parseTranslateString, translateString } from '../util/svg'; import { clamp } from './utilities'; import { DCG_CLASS } from './connection-map.class'; import { buildHoverMenu } from './subsidiary-hover-menu'; import { buildSubsidiaryBox } from './subsidiary-box'; export const SG_CLASS = 'subsidiary-company'; export const SG_CLASS_WITH_ID = id => `${SG_CLASS}__${id}`; export const SG_CLASS_CONTAINER = 'subsidiaries'; export const SG_CLASS_HAS_SUBS = `${SG_CLASS}__has-subs`; export const SG_CLASS_CIRCLE_G = `${SG_CLASS}__circle-group`; export const SG_CLASS_IS_SCOUT_COMPANY = `${SG_CLASS}__is-scout-company`; export const SG_CLASS_PATH = `${SG_CLASS}__path`; export const SG_CLASS_LINKS = `${SG_CLASS}__links`; export const SG_CLASS_SUBS_TEXT = `${SG_CLASS}__subs-text`; export const SG_CLASS_CORE_GROUP = `${SG_CLASS}__core-group`; export const SG_CLASS_CARD_GROUP = `${SG_CLASS}__card-group`; export const SG_CLASS_SUBSISIARY_BOX = `${SG_CLASS}__subsidiary-box`; export const SG_CLASS_HOVER_MENU = `${SG_CLASS}__hover-menu`; export const SG_TEXT_SUBS_OPEN = '-'; export const SG_TEXT_SUBS_CLOSED = '+'; export const SG_WIDTH = 264; export const SG_WIDTH_HAS_SUBS = 280; export const SG_HEIGHT = 48; const SG_MARGIN_BOT = 16; const SG_PARENT_OFFSET = 186 / 2; const TRANSITION_DURATION = 500; const SG_SUBS_TEXT_HEIGHT = 16; const SG_SUBS_TEXT_OFFSET = 40; export const HOVER_MENU_WIDTH = SG_WIDTH / 1.2; export interface ITreeFilterConfig { AND: Array; OR: Array; } export interface ISubsidiaryFilterConfig { propertyPath: Array; value: boolean | Array; // boolean for OR, array for AND } export interface ISubsidiaryCompanyData extends IConnectionCompanyData { hidden?: boolean; __hidden__?: boolean; // used in filtering process nonHiddenSubsidiaries?: boolean; } const SUBSIDIARY_LIST_CONTAINER_CLASS_LIST_FROM_PARENT = parent => [ SG_CLASS_CONTAINER, `${SG_CLASS_CONTAINER}--${parent.data()[0].entityId}` ]; export type D3Selection = d3.Selection; export type ConnectionCompanySVGSelection = d3.Selection< SVGGElement, IConnectionCompanyData, SVGGElement, IConnectionCompanyData >; export default class SubsidiaryList { NODE: any; PARENT: ConnectionCompanySVGSelection; PARENT_LIST?: SubsidiaryList; CONTAINER: ConnectionCompanySVGSelection; SOURCE_DATA; DATA; HOVER_MENU: D3Selection; HOVER_MENU_CONTENT; HOVERED_SUBSIDIARY: SVGGElement; PARENT_BB: SVGRect; PARENT_TRANSLATE: { x: number; y: number } = { x: 0, y: 0 }; SG_X: number; PARENT_RIGHT_CORNER_X: number; PARENT_RIGHT_MIDDLE_Y: number; 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; ON_SUBSIDIARY_LIST_CLICK: d3Select.ValueFn< SVGElement, ISubsidiaryCompanyData, void >; constructor( container, parent, parentList: SubsidiaryList, subsidiariesData: Array, externalActions: { [x: string]: any }, preAppliedFilters: ITreeFilterConfig ) { this.PARENT = parent; this.PARENT_LIST = parentList; this.CONTAINER = container; this.SOURCE_DATA = subsidiariesData; this.DATA = cloneDeep(this.SOURCE_DATA); for (let key in externalActions) { this[key] = externalActions[key]; } this._setupBaseDimensions(); this.DATA = this.DATA.map(this._linksFromDataMapper); this._updateParentDimensions(); if (preAppliedFilters) { this.applyFilters(preAppliedFilters); } else { this.render(this.PARENT.attr('class') !== DCG_CLASS); } this._handleHoverMenuMouseMove = this._handleHoverMenuMouseMove.bind( this ); } private _hoverMenuCoords = (subsidiaries, index) => { const centerOffset = (subsidiaries.length > 0 ? SG_WIDTH_HAS_SUBS : SG_WIDTH) / 2 - HOVER_MENU_WIDTH / 2; let aggregatedParentsTranslateX = 0; let aggregatedParentsTranslateY = 0; let aggregatingSubsidiaryList: SubsidiaryList = this; while (aggregatingSubsidiaryList != null) { aggregatingSubsidiaryList._updateParentDimensions(); // private aggregatedParentsTranslateX += aggregatingSubsidiaryList.PARENT_TRANSLATE.x; aggregatedParentsTranslateY += aggregatingSubsidiaryList.PARENT_TRANSLATE.y; aggregatingSubsidiaryList = aggregatingSubsidiaryList.PARENT_LIST; } return { x: aggregatedParentsTranslateX + this.SG_X + centerOffset, y: aggregatedParentsTranslateY + this.getY(index) + SG_HEIGHT - 2 }; }; private _updateParentDimensions = () => { this.PARENT_TRANSLATE = parseTranslateString( this.PARENT.attr('transform') ); }; private _setupBaseDimensions = () => { this.PARENT_RIGHT_CORNER_X = this.PARENT.attr('class') === DCG_CLASS ? parseInt(this.PARENT.attr('x'), 10) : SG_WIDTH_HAS_SUBS + SG_MARGIN_BOT; this.PARENT_RIGHT_MIDDLE_Y = SG_HEIGHT / 2; this.SG_X = this.PARENT_RIGHT_CORNER_X + SG_PARENT_OFFSET; this.getY = index => index * (SG_HEIGHT + SG_MARGIN_BOT); }; private _buildHoverMenu = (data, index, nodes) => { this.removeHoverMenu(); this.HOVERED_SUBSIDIARY = nodes[index]; this.HOVER_MENU = this.CONTAINER.append('g').attr( 'class', SG_CLASS_HOVER_MENU ); const coords = this._hoverMenuCoords(data.subsidiaries, index); buildHoverMenu(this.HOVER_MENU, coords, data, { addToTargetList: this.ON_ADD_TO_TARGET_LIST, companyPageNavigate: this.ON_COMPANY_PAGE_NAVIGATE }); document.addEventListener('mousemove', this._handleHoverMenuMouseMove); }; removeHoverMenu = () => { d3Select.selectAll(`g.${SG_CLASS}__hover-menu`).remove(); }; private _linksFromDataMapper = (d: IConnectionCompanyData, index, data) => { const link = { source: { x: this.PARENT_RIGHT_CORNER_X, y: this.PARENT_RIGHT_MIDDLE_Y }, target: { x: this.SG_X, y: this.getY(index) + SG_HEIGHT / 2 } }; const dx = Math.abs(link.target.x - link.source.x); let lineData: Array>; if (index === data.length - 1) { lineData = [ [link.source.x, link.source.y], [link.source.x + dx / 2, link.source.y], [link.target.x - (dx / 2 - 1), link.target.y], [link.target.x, link.target.y] ]; } else { lineData = [ [link.target.x - (dx / 2 - 1), link.target.y], [link.target.x, link.target.y] ]; } d['link'] = lineData; return d; }; private _handleHoverMenuMouseMove(event: MouseEvent) { const target = event.target as Node; if ( !this.HOVERED_SUBSIDIARY.contains(target) && !(this.HOVER_MENU.node() as Node).contains(target) ) { this.removeHoverMenu(); document.removeEventListener( 'mousemove', this._handleHoverMenuMouseMove ); } } // Overwritten later getY: (index) => number = index => 0; render(transition: boolean = false) { // CARDS ============================================================ // const subsidiaryGroupClassList = SUBSIDIARY_LIST_CONTAINER_CLASS_LIST_FROM_PARENT( this.PARENT ); const prevSubsidiaryGroup = this.CONTAINER.selectAll( `.${subsidiaryGroupClassList[1]}` ); const subsidiaryGroup = (this.NODE = !prevSubsidiaryGroup.empty() ? prevSubsidiaryGroup : this.PARENT.insert('g', ':first-child').attr( 'class', subsidiaryGroupClassList.join(' ') )); const subsidiariesGroupsUpdate = this.NODE.selectAll(`g.${SG_CLASS}`) .filter( (data, index, nodes) => (nodes[index] as HTMLElement).parentNode === subsidiaryGroup.node() ) .data(this.DATA, (d: ISubsidiaryCompanyData) => d.entityId); let subsidiariesGroupsRemoved = subsidiariesGroupsUpdate.exit(); let subsidiariesGroupsEntered = subsidiariesGroupsUpdate .enter() .append('g') .attr('class', (d: ISubsidiaryCompanyData) => cx(SG_CLASS, SG_CLASS_WITH_ID(d.entityId), { [SG_CLASS_HAS_SUBS]: d.subsidiaries.length > 0, [SG_CLASS_IS_SCOUT_COMPANY]: d.isScoutCompany }) ) .attr('transform', (data, index) => translateString(this.SG_X, this.getY(0)) ); let subsidiariesGroups = subsidiariesGroupsEntered .append('g') .attr('class', SG_CLASS_CORE_GROUP) .append('g') .attr('class', SG_CLASS_CARD_GROUP) .style('opacity', d => (d.hidden ? 0.2 : 1)); buildSubsidiaryBox(subsidiariesGroups); subsidiariesGroupsEntered .filter(d => d.subsidiaries.length) .append('text') .attr('class', SG_CLASS_SUBS_TEXT) .attr('font-size', 14) .attr('line-height', 16) .attr('height', SG_SUBS_TEXT_HEIGHT) .attr('x', SG_WIDTH + SG_SUBS_TEXT_OFFSET) .attr('dy', '0.35em') .attr('y', SG_HEIGHT / 2); subsidiariesGroupsEntered .merge(subsidiariesGroupsUpdate) .each(function(data) { d3Select .select(this) .selectAll(`.${SG_CLASS_SUBS_TEXT}`) .style('opacity', d => data.hidden && data.nonHiddenSubsidiaries === false ? 0.2 : 1 ) .text(d => { const nonHidden = data.subsidiaries.filter( d => !d.__hidden__ ).length; return data.nonHiddenSubsidiaries ? `${nonHidden} of ${data.subsidiaries.length}` : data.subsidiaries.length; }); }); // CIRCLE BUTTONS ============================================================ // const subsidiariesGroupsCircles = subsidiariesGroupsEntered .filter(d => d.subsidiaries.length > 0) .selectAll(`.${SG_CLASS_CORE_GROUP}`) .append('g') .attr('class', d => cx(SG_CLASS_CIRCLE_G, { [SG_CLASS_HAS_SUBS]: d.subsidiaries.length > 0, [SG_CLASS_IS_SCOUT_COMPANY]: d.isScoutCompany }) ) .attr('height', 32) .attr('width', 32) .attr('transform', (data, index) => translateString(SG_WIDTH_HAS_SUBS, SG_HEIGHT / 2) ) .style('opacity', d => d.hidden && d.nonHiddenSubsidiaries === false ? 0.2 : 1 ) .classed( 'disabled', d => d.hidden && d.nonHiddenSubsidiaries === false ) .on('click', this.ON_SUBSIDIARY_LIST_CLICK); subsidiariesGroupsCircles .append('circle') .style('fill', COLOUR.SHUTTLE_GREY) .attr('r', 16); subsidiariesGroupsCircles .append('text') .text(SG_TEXT_SUBS_CLOSED) .attr('text-anchor', 'middle') .attr('font-family', 'sans-serif') .attr('font-size', 22) .attr('dy', () => (isMSEdge() ? '8px' : '5px')) .attr('fill', COLOUR.WHITE); // EVENTS ========================================================= // subsidiariesGroupsEntered .merge(subsidiariesGroupsUpdate) .each((data, index, nodes) => { d3Select .select(nodes[index]) .selectAll(`.${SG_CLASS_CARD_GROUP}`) .on('click', d => data.isScoutCompany ? this.ON_CONNECTION_NAVIGATE(data.company.scoutId) : d3Select.event.preventDefault() ) .on( 'mouseover', this._buildHoverMenu.bind(this, data, index, nodes) ); }); // UPDATES ========================================================= // if (transition) { subsidiariesGroupsRemoved .transition() .duration(TRANSITION_DURATION) .style('opacity', 0) .remove(); subsidiariesGroupsUpdate .transition() .duration(TRANSITION_DURATION) .attr('transform', (data, index) => translateString(this.SG_X, this.getY(index)) ); // Filtering opacity subsidiariesGroupsUpdate.each(function(data) { d3Select .select(this) .selectAll(`.${SG_CLASS_CARD_GROUP}`) .transition() .duration(TRANSITION_DURATION) .style('opacity', data.hidden ? 0.2 : 1); d3Select .select(this) .selectAll(`.${SG_CLASS_CIRCLE_G},.${SG_CLASS_SUBS_TEXT}`) .classed( 'disabled', d => data.nonHiddenSubsidiaries === false ) .transition() .duration(TRANSITION_DURATION) .style( 'opacity', data.nonHiddenSubsidiaries === false ? 0.2 : 1 ); }); subsidiariesGroupsEntered .style('opacity', 0) .transition() .duration(TRANSITION_DURATION) .attr('transform', (data, index) => translateString(this.SG_X, this.getY(index)) ) .style('opacity', 1); } else { subsidiariesGroupsRemoved.remove(); subsidiariesGroupsUpdate.attr('transform', (data, index) => translateString(this.SG_X, this.getY(index)) ); subsidiariesGroupsUpdate.each(function(data) { d3Select .select(this) .selectAll(`.${SG_CLASS_CARD_GROUP}`) .style('opacity', data.hidden ? 0.2 : 1); d3Select .select(this) .selectAll(`.${SG_CLASS_CIRCLE_G},.${SG_CLASS_SUBS_TEXT}`) .classed( 'disabled', d => data.nonHiddenSubsidiaries === false ) .style( 'opacity', data.nonHiddenSubsidiaries === false ? 0.2 : 1 ); }); subsidiariesGroupsUpdate.each(function(data) { d3Select .select(this) .selectAll(`.${SG_CLASS_CARD_GROUP}`) .style('opacity', data.hidden ? 0.2 : 1); }); subsidiariesGroupsEntered.attr('transform', (data, index) => translateString(this.SG_X, this.getY(index)) ); } // Line clamp ============================================================ / clamp(subsidiaryGroup, '.subsidiary-company-card__name', 2); // LINKS ============================================================ // const subsidiariesLinksGroupsSelection = subsidiaryGroup .selectAll(`.${SG_CLASS_LINKS}` as any) .filter( (data, index, nodes) => (nodes[index] as HTMLElement).parentNode === subsidiaryGroup.node() ); const subsidiariesLinksGroup = subsidiariesLinksGroupsSelection.size() ? subsidiariesLinksGroupsSelection : (subsidiaryGroup as any) .append('g') .attr('class', SG_CLASS_LINKS); const lineGenerator = d3Shape.line(); const links = subsidiariesLinksGroup .selectAll(`path.${SG_CLASS_PATH}`) .data(this.DATA, (d: ISubsidiaryCompanyData) => d.entityId); const linksExited = links.exit(); const linksEntered = links .enter() .append('path') .attr('class', SG_CLASS_PATH) .attr('fill', 'none') .attr('stroke', COLOUR.ALUMINIUM) .attr('stroke-width', 2) .style('opacity', d => (d.hidden ? 0.2 : 1)); if (transition) { linksExited .transition() .duration(TRANSITION_DURATION) .style('opacity', 0) .remove(); linksEntered .style('opacity', 0) .transition() .duration(TRANSITION_DURATION) .style('opacity', d => (d.hidden ? 0.2 : 1)); links .merge(linksEntered) .attr('d', (d: any) => lineGenerator(d.link)) .style('opacity', d => (d.hidden ? 0.2 : 1)); } else { linksExited.remove(); linksEntered .merge(links) .attr('d', (d: any) => lineGenerator(d.link)); } this._updateParentDimensions(); } applyFilters(filters: ITreeFilterConfig, transition: boolean = true) { let filteredData: Array = cloneDeep( this.SOURCE_DATA ); if (filters && (filters.OR.length || filters.AND.length)) { const filtersByPath: { OR: { [x: string]: Array }; AND: { [x: string]: Array }; } = { OR: {}, AND: {} }; const _filtersByPath = (type, filter) => { const joinedPath = filter.propertyPath.join(','); if ( has( filtersByPath, [].concat([type], filter.propertyPath) ) && filter.value !== null ) { filtersByPath[type][joinedPath].push(filter.value); } else if (filter.value !== null) { filtersByPath[type][joinedPath] = [filter.value]; } }; filters.AND.forEach(_filtersByPath.bind(null, 'AND')); filters.OR.forEach(_filtersByPath.bind(null, 'OR')); const hasNonHiddenSubsidiaries = ( subsidiaries: Array, curriedIsHidden: (item: ISubsidiaryCompanyData) => boolean ): boolean => subsidiaries.reduce((prev, current, i) => { const isHidden = curriedIsHidden(current); current.__hidden__ = current.__hidden__ || isHidden; return ( prev || !current.__hidden__ || hasNonHiddenSubsidiaries( current.subsidiaries, curriedIsHidden ) ); }, false); filteredData = filteredData.map(item => { item.hidden = false; item.nonHiddenSubsidiaries = !!item.subsidiaries.length; Object.keys(filtersByPath.AND).forEach(path => { const values = filtersByPath.AND[path]; type curryableIsHidden = ( path: Array, values: Array ) => (item: ISubsidiaryCompanyData) => boolean; const isHidden: curryableIsHidden = ( path, values ) => item => values .map(value => get(item, path, null) !== value) .filter(value => value !== null) .some(value => value); const curriedIsHidden = isHidden(path.split(','), values); item.hidden = item.hidden || curriedIsHidden(item); if (item.subsidiaries.length > 0) { item.nonHiddenSubsidiaries = item.nonHiddenSubsidiaries && hasNonHiddenSubsidiaries( item.subsidiaries, curriedIsHidden ); } }); Object.keys(filtersByPath.OR).forEach(path => { const values = filtersByPath.OR[path]; type curryableIsHidden = ( path: Array, values: Array ) => (item: ISubsidiaryCompanyData) => boolean; const isHidden: curryableIsHidden = ( path, values ) => item => values .map(value => get(item, path, null) !== value) .filter(value => value !== null) .every(value => value); const curriedIsHidden = isHidden(path.split(','), values); item.hidden = item.hidden || curriedIsHidden(item); if (item.subsidiaries.length > 0) { item.nonHiddenSubsidiaries = item.nonHiddenSubsidiaries && hasNonHiddenSubsidiaries( item.subsidiaries, curriedIsHidden ); } }); return item; }); } this.DATA = filteredData.filter( item => !item.hidden || (item.hidden && item.nonHiddenSubsidiaries) ); this._setupBaseDimensions(); this.DATA = this.DATA.map(this._linksFromDataMapper); this.render(transition); } }