import { Component } from 'vue-property-decorator' ; import { select } from 'd3-selection' ; import Victor from 'victor' ; import _ from 'lodash' ; import $ from 'jquery' ; import { Visualization , injectVisualizationTemplate , multiplyVisuals , Scale , AnyScale , getScale , drawAxis , DEFAULT_PLOT_MARGINS , LABEL_OFFSET_PX , PlotMargins , drawBrushBox , getBrushBox , isPointInBox , } from '@/components/visualization' ; import template from './scatterplot.html' ; import ColumnSelect from '@/components/column-select/column-select' ; import { SELECTED_COLOR } from '@/common/constants' ; import { isNumericalType } from '@/data/util' ; import { fadeOut , getTransform } from '@/common/util' ; import { VisualProperties } from '@/data/visuals' ; import * as history from './history' ; const DOMAIN_MARGIN = .1 ; interface ScatterplotSave { xColumn : number | null ; yColumn : number | null ; areXAxisTicksVisible : boolean ; areYAxisTicksVisible : boolean ; axisMargin : boolean ; useDatasetRange : boolean ; transparentBackground : boolean ; } interface ScatterplotItemProps { index : number ; x : number | string ; y : number | string ; visuals : VisualProperties ; hasVisuals : boolean ; selected : boolean ; } const DEFAULT_ITEM_VISUALS = { color : '#333' , border : 'black' , width : 1 , size : 5 , opacity : 1 } ; const SELECTED_ITEM_VISUALS = { color : 'white' , border : SELECTED_COLOR } ; @ Component ( { template : injectVisualizationTemplate ( template ) , components : { ColumnSelect } } ) export default class Scatterplot extends Visualization { protected NODE_TYPE = 'scatterplot' ; protected HAS_SETTINGS = true ; private xColumn : number | null = null ; private yColumn : number | null = null ; private margins : PlotMargins = { ... DEFAULT_PLOT_MARGINS , bottom : 20 } ; private xScale ! : Scale ; private yScale ! : Scale ; private itemProps : ScatterplotItemProps [ ] = [ ] ; private useDatasetRange = false ; private axisMargin = true ; private areXAxisTicksVisible = true ; private areYAxisTicksVisible = true ; private transparentBackground = false ; public setXColumn ( column ) { this . xColumn = column ; this . draw ( ) ; } public setYColumn ( column ) { this . yColumn = column ; this . draw ( ) ; } public setTransitionDisabled ( value ) { this . isTransitionDisabled = value ; } public applyColumns ( columns ) { if ( columns . length === 0 ) { this . findDefaultColumns ( ) ; } else if ( columns . length === 1 ) { this . xColumn = this . yColumn = columns [ 0 ] ; } else { this . xColumn = columns [ 0 ] ; this . yColumn = columns [ 1 ] ; } this . draw ( ) ; } public setUseDatasetRange ( value ) { this . useDatasetRange = value ; this . draw ( ) ; } public setXAxisTicksVisible ( value ) { this . areXAxisTicksVisible = value ; this . draw ( ) ; } public setYAxisTicksVisible ( value ) { this . areYAxisTicksVisible = value ; this . draw ( ) ; } public setAxisMargin ( value ) { this . axisMargin = value ; this . draw ( ) ; } public setTransparentBackground ( value ) { this . backgroundColor = value ? 'none' : 'white' ; } protected created ( ) { this . serializationChain . push ( ( ) : ScatterplotSave => ( { xColumn : this . xColumn , yColumn : this . yColumn , useDatasetRange : this . useDatasetRange , axisMargin : this . axisMargin , areXAxisTicksVisible : this . areXAxisTicksVisible , areYAxisTicksVisible : this . areYAxisTicksVisible , transparentBackground : this . transparentBackground } ) ) ; this . deserializationChain . push ( nodeSave => { const save = nodeSave as ScatterplotSave ; if ( save . transparentBackground ) { this . setTransparentBackground ( true ) ; } } ) ; } protected draw ( ) { if ( ! this . hasDataset ( ) ) { return ; } if ( this . xColumn === null || this . yColumn === null ) { this . coverText = 'Please choose columns' ; return ; } this . coverText = '' ; this . computeItemProps ( ) ; this . computeScales ( ) ; this . updateLeftMargin ( ) ; this . updateBottomMargin ( ) ; this . drawXAxis ( ) ; this . drawYAxis ( ) ; this . drawPoints ( ) ; } protected brushed ( brushPoints , isBrushStop ? ) { if ( isBrushStop ) { this . computeBrushedItems ( brushPoints ) ; this . computeSelection ( ) ; this . computeItemProps ( ) ; this . drawPoints ( ) ; this . propagateSelection ( ) ; } drawBrushBox ( this . $refs . brush as SVGElement , ! isBrushStop ? brushPoints : [ ] ) ; } protected findDefaultColumns ( ) { if ( ! this . hasDataset ( ) ) { return ; } const dataset = this . getDataset ( ) ; const numericalColumns = dataset . getColumns ( ) . filter ( column => isNumericalType ( column . type ) ) . slice ( 0 , 2 ) . map ( column => column . index ) ; if ( this . xColumn !== null ) { this . xColumn = this . updateColumnOnDatasetChange ( this . xColumn ) ; } else { this . xColumn = numericalColumns . shift ( ) || null ; } if ( this . yColumn !== null ) { this . yColumn = this . updateColumnOnDatasetChange ( this . yColumn ) ; } else { this . yColumn = numericalColumns . shift ( ) || null ; } } private computeBrushedItems ( brushPoints ) { if ( brushPoints . length <= 1 ) { return ; } if ( ! this . isShiftPressed || ! brushPoints . length ) { this . selection . clear ( ) ; if ( ! brushPoints . length ) { return ; } } const box = getBrushBox ( brushPoints ) ; this . itemProps . forEach ( props => { const point = { x : this . xScale ( props . x ) , y : this . yScale ( props . y ) } ; const clickToPointDistance = new Victor ( point . x , point . y ) . subtract ( new Victor ( brushPoints [ 0 ] . x , brushPoints [ 0 ] . y ) ) . length ( ) ; if ( isPointInBox ( point , box ) || clickToPointDistance <= ( props . visuals . size as number ) / 2 ) { this . selection . addItem ( props . index ) ; } } ) ; } private computeItemProps ( ) { const pkg = this . inputPortMap . in . getSubsetPackage ( ) ; const items = pkg . getItems ( ) ; this . itemProps = items . map ( item => { const props = { index : item . index , x : this . getDataset ( ) . getCellForScale ( item , this . xColumn as number ) , y : this . getDataset ( ) . getCellForScale ( item , this . yColumn as number ) , visuals : _ . extend ( { } , DEFAULT_ITEM_VISUALS , item . visuals ) , hasVisuals : ! _ . isEmpty ( item . visuals ) , selected : this . selection . hasItem ( item . index ) } ; if ( props . selected ) { _ . extend ( props . visuals , SELECTED_ITEM_VISUALS ) ; multiplyVisuals ( props . visuals ) ; } return props ; } ) ; } private drawPoints ( ) { const useTransition = this . isTransitionFeasible ( this . itemProps . length ) ; const svgPoints = select ( this . $refs . points as SVGGElement ) ; let points = svgPoints . selectAll < SVGGraphicsElement , ScatterplotItemProps > ( 'circle' ) . data ( this . itemProps , d => d . index . toString ( ) ) ; if ( useTransition ) { fadeOut ( points . exit ( ) ) ; } else { points . exit ( ) . remove ( ) ; } points = points . enter ( ) . append < SVGGraphicsElement > ( 'circle' ) . attr ( 'id' , d => d . index . toString ( ) ) . merge ( points ) . attr ( 'has-visuals' , d => d . hasVisuals ) . attr ( 'is-selected' , d => d . selected ) ; const updatedPoints = useTransition ? points . transition ( ) : points ; updatedPoints . attr ( 'cx' , d => this . xScale ( d . x ) ) . attr ( 'cy' , d => this . yScale ( d . y ) ) . attr ( 'r' , d => ( d . visuals . size as number / 2 ) + 'px' ) . style ( 'fill' , d => d . visuals . color as string ) . style ( 'stroke' , d => d . visuals . border as string ) . style ( 'stroke-width' , d => d . visuals . width + 'px' ) . style ( 'opacity' , d => d . visuals . opacity as number ) ; this . moveSelectedPointsToFront ( ) ; } /**
     * Moves the selected points so that they appear above the other points to be clearly visible.
     * Elements that appear later in an SVG are rendered on the top.
     */ private moveSelectedPointsToFront ( ) { const gPoints = this . $refs . points as SVGGElement ; const $points = $ ( gPoints ) ; $points . children ( 'circle[has-visuals=true]' ) . appendTo ( gPoints ) ; $points . children ( 'circle[is-selected=true]' ) . appendTo ( gPoints ) ; } private computeScales ( ) { const items = this . inputPortMap . in . getSubsetPackage ( ) . getItemIndices ( ) ; const dataset = this . getDataset ( ) ; const xDomain = dataset . getDomain ( this . xColumn as number , this . useDatasetRange ? undefined : items ) ; const yDomain = dataset . getDomain ( this . yColumn as number , this . useDatasetRange ? undefined : items ) ; [ this . xScale , this . yScale ] = [ getScale ( dataset . getColumnType ( this . xColumn as number ) , xDomain , [ this . margins . left , this . svgWidth - this . margins . right ] , { domainMargin : this . axisMargin ? DOMAIN_MARGIN : 0 } ) , getScale ( dataset . getColumnType ( this . yColumn as number ) , yDomain , [ this . svgHeight - this . margins . bottom , this . margins . top ] , { domainMargin : this . axisMargin ? DOMAIN_MARGIN : 0 } ) , ] ; } private drawXAxis ( ) { drawAxis ( this . $refs . xAxis as SVGElement , this . xScale , { classes : 'x' , orient : 'bottom' , ticks : ! this . areXAxisTicksVisible ? 0 : undefined , transform : getTransform ( [ 0 , this . svgHeight - this . margins . bottom ] ) , label : { text : this . getDataset ( ) . getColumnName ( this . xColumn as number ) , transform : getTransform ( [ this . svgWidth - this . margins . right , - LABEL_OFFSET_PX ] ) } } ) ; } private drawYAxis ( ) { drawAxis ( this . $refs . yAxis as SVGElement , this . yScale , { classes : 'y' , orient : 'left' , ticks : ! this . areYAxisTicksVisible ? 0 : undefined , transform : getTransform ( [ this . margins . left , 0 ] ) , label : { text : this . getDataset ( ) . getColumnName ( this . yColumn as number ) , transform : getTransform ( [ LABEL_OFFSET_PX , this . margins . top ] , 1 , 90 ) } } ) ; } /**
     * Determines the maximum length of the y axis ticks and sets the left margin accordingly.
     */ private updateLeftMargin ( ) { this . drawYAxis ( ) ; this . updateMargins ( ( ) => { const maxTickWidth = _ . max ( $ ( this . $refs . yAxis as SVGGElement ) . find ( '.y > .tick > text' ) . map ( ( index , element ) => element . getBBox ( ) . width ) ) || 0 ; this . margins . left = DEFAULT_PLOT_MARGINS . left + maxTickWidth ; ( this . xScale as AnyScale ) . range ( [ this . margins . left , this . svgWidth - this . margins . right ] ) ; } ) ; } /**
     * Determines the bottom margin depending on whether X axis ticks are shown.
     */ private updateBottomMargin ( ) { this . drawXAxis ( ) ; this . updateMargins ( ( ) => { const maxTickHeight = _ . max ( $ ( this . $refs . xAxis as SVGGElement ) . find ( '.x > .tick > text' ) . map ( ( index , element ) => element . getBBox ( ) . height ) ) || 0 ; this . margins . bottom = DEFAULT_PLOT_MARGINS . bottom + maxTickHeight ; ( this . yScale as AnyScale ) . range ( [ this . svgHeight - this . margins . bottom , this . margins . top ] ) ; } ) ; } private onSelectXColumn ( column , prevColumn ) { this . commitHistory ( history . selectXColumnEvent ( this , column , prevColumn ) ) ; this . setXColumn ( column ) ; } private onSelectYColumn ( column , prevColumn ) { this . commitHistory ( history . selectYColumnEvent ( this , column , prevColumn ) ) ; this . setYColumn ( column ) ; } private onToggleTransitionDisabled ( value ) { this . commitHistory ( history . toggleTransitionDisabledEvent ( this , value ) ) ; this . setTransitionDisabled ( value ) ; } private onToggleUseDatasetRange ( value ) { this . commitHistory ( history . toggleUseDatasetRangeEvent ( this , value ) ) ; this . setUseDatasetRange ( value ) ; } private onToggleXAxisTicksVisible ( value ) { this . commitHistory ( history . toggleXAxisTicksVisibleEvent ( this , value ) ) ; this . setXAxisTicksVisible ( value ) ; } private onToggleYAxisTicksVisible ( value ) { this . commitHistory ( history . toggleYAxisTicksVisibleEvent ( this , value ) ) ; this . setYAxisTicksVisible ( value ) ; } private onToggleAxisMargin ( value ) { this . commitHistory ( history . toggleAxisMarginEvent ( this , value ) ) ; this . setAxisMargin ( value ) ; } private onToggleTransparentBackground ( value ) { this . commitHistory ( history . toggleTransparentBackgroundEvent ( this , value ) ) ; this . setTransparentBackground ( value ) ; } }