diff --git a/src/components/MultiLink.vue b/src/components/MultiLink.vue index 56535c01..a2f65e20 100644 --- a/src/components/MultiLink.vue +++ b/src/components/MultiLink.vue @@ -10,7 +10,7 @@ import { select } from 'd3-selection'; import store from '@/store'; import { - Node, Edge, SimulationEdge, + Node, Edge, SimulationEdge, AttributeRange, } from '@/types'; import ContextMenu from '@/components/ContextMenu.vue'; @@ -20,6 +20,7 @@ import { } from '@vue/composition-api'; import { axisBottom, axisLeft } from 'd3-axis'; import { isInternalField } from '@/lib/typeUtils'; +import { ColumnType } from 'multinet'; export default defineComponent({ components: { @@ -67,6 +68,7 @@ export default defineComponent({ const controlsWidth = computed(() => store.state.controlsWidth); const directionalEdges = computed(() => store.state.directionalEdges); const edgeColorScale = computed(() => store.getters.edgeColorScale); + const clipRegionSize = 100; // Update height and width as the window size changes // Also update center attraction forces as the size changes @@ -591,29 +593,163 @@ export default defineComponent({ } }); + const xAxisPadding = 60; + const yAxisPadding = 80; const layoutVars = computed(() => store.state.layoutVars); - watch(layoutVars, () => { + function makePositionScale(axis: 'x' | 'y', type: ColumnType, range: AttributeRange) { + const varName = layoutVars.value[axis]; + let clipLow = false; + let clipHigh = false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let positionScale: any; + + if (type === 'number') { + let minValue = range.min; + let maxValue = range.max - 1; // subtract 1, because of the + 1 on the legend chart scale + + // Check IQR for outliers + if (network.value !== null && varName !== null) { + const values = network.value.nodes.map((node) => node[varName]).sort((a, b) => a - b); + + let q1; + let q3; + if ((values.length / 4) % 1 === 0) { + q1 = 0.5 * (values[(values.length / 4)] + values[(values.length / 4) + 1]); + q3 = 0.5 * (values[(values.length * (3 / 4))] + values[(values.length * (3 / 4)) + 1]); + } else { + q1 = values[Math.floor(values.length / 4 + 1)]; + q3 = values[Math.ceil(values.length * (3 / 4) + 1)]; + } + + const iqr = q3 - q1; + const maxCandidate = q3 + iqr * 1.5; + const minCandidate = q1 - iqr * 1.5; + + if (maxCandidate < maxValue) { + maxValue = maxCandidate; + clipHigh = true; + select(`#${axis}-high-clip`).style('visibility', 'visible'); + select(`#${axis}-high-clip > text`).text(`> ${maxCandidate}`); + } + + if (minCandidate > minValue) { + minValue = minCandidate; + clipLow = true; + select(`#${axis}-low-clip`).style('visibility', 'visible'); + select(`#${axis}-low-clip > text`).text(`< ${minCandidate}`); + } + } + + positionScale = scaleLinear() + .domain([minValue, maxValue]); + } else { + positionScale = scaleBand() + .domain(range.binLabels); + } + + if (axis === 'x') { + const minMax = [clipLow ? yAxisPadding + clipRegionSize : yAxisPadding, clipHigh ? store.state.svgDimensions.width - clipRegionSize : store.state.svgDimensions.width]; + positionScale = positionScale + .range(minMax); + } else { + const minMax = [clipLow ? store.state.svgDimensions.height - xAxisPadding - clipRegionSize : store.state.svgDimensions.height - xAxisPadding, clipHigh ? clipRegionSize : 0]; + positionScale = positionScale + .range(minMax); + } + + const otherAxis = axis === 'x' ? 'y' : 'x'; + + if (varName !== null) { + // Set node size smaller + store.commit.setMarkerSize({ markerSize: 10, updateProv: true }); + + // Clear the label variable + store.commit.setLabelVariable(undefined); + + store.commit.stopSimulation(); + + if (store.state.network !== null && store.state.columnTypes !== null) { + const otherAxisPadding = axis === 'x' ? 80 : 60; + + if (type === 'number') { + const scaleDomain = positionScale.domain(); + const scaleRange = positionScale.range(); + store.state.network.nodes.forEach((node) => { + const nodeVal = node[varName]; + let position = positionScale(nodeVal); + + if (axis === 'x') { + position = nodeVal > scaleDomain[1] ? scaleRange[1] + ((clipRegionSize - 10) * ((nodeVal - scaleDomain[1]) / (range.max - 1 - scaleDomain[1]))) : position; + position = nodeVal < scaleDomain[0] ? scaleRange[0] - ((clipRegionSize - 10) * ((scaleDomain[0] - nodeVal) / (scaleDomain[0] - range.min))) : position; + } else { + position = nodeVal > scaleDomain[1] ? scaleRange[1] - ((clipRegionSize - 10) * ((nodeVal - scaleDomain[1]) / (range.max - 1 - scaleDomain[1]))) : position; + position = nodeVal < scaleDomain[0] ? scaleRange[0] + ((clipRegionSize - 10) * ((scaleDomain[0] - nodeVal) / (scaleDomain[0] - range.min))) : position; + } + position -= (markerSize.value / 2); + + // eslint-disable-next-line no-param-reassign + node[axis] = position; + // eslint-disable-next-line no-param-reassign + node[`f${axis}`] = position; + + if (store.state.layoutVars[otherAxis] === null) { + const otherSvgDimension = axis === 'x' ? store.state.svgDimensions.height : store.state.svgDimensions.width; + // eslint-disable-next-line no-param-reassign + node[otherAxis] = otherSvgDimension / 2; + // eslint-disable-next-line no-param-reassign + node[`f${otherAxis}`] = otherSvgDimension / 2; + } + }); + } else { + let positionOffset: number; + + if (axis === 'x') { + positionOffset = (store.state.svgDimensions.width - otherAxisPadding) / ((range.binLabels.length) * 2); + } else { + positionOffset = (store.state.svgDimensions.height - xAxisPadding - 10) / ((range.binLabels.length) * 2); + } + + store.state.network.nodes.forEach((node) => { + // eslint-disable-next-line no-param-reassign + node[axis] = (positionScale(node[varName]) || 0) + positionOffset; + // eslint-disable-next-line no-param-reassign + node[`f${axis}`] = (positionScale(node[varName]) || 0) + positionOffset; + + if (store.state.layoutVars[otherAxis] === null) { + const otherSvgDimension = axis === 'x' ? store.state.svgDimensions.height : store.state.svgDimensions.width; + // eslint-disable-next-line no-param-reassign + node[otherAxis] = otherSvgDimension / 2; + // eslint-disable-next-line no-param-reassign + node[`f${otherAxis}`] = otherSvgDimension / 2; + } + }); + } + } + } else if (store.state.layoutVars[otherAxis] === null) { + store.dispatch.releaseNodes(); + } + + return positionScale; + } + + function resetAxesClipRegions() { select('#axes').selectAll('g').remove(); - const xAxisPadding = 60; - const yAxisPadding = 80; + + select('#x-low-clip').style('visibility', 'hidden'); + select('#x-high-clip').style('visibility', 'hidden'); + select('#y-low-clip').style('visibility', 'hidden'); + select('#y-high-clip').style('visibility', 'hidden'); + } + + watch(layoutVars, () => { + resetAxesClipRegions(); // Add x layout if (store.state.columnTypes !== null && layoutVars.value.x !== null) { const type = store.state.columnTypes[layoutVars.value.x]; const range = store.state.attributeRanges[layoutVars.value.x]; - const maxPosition = store.state.svgDimensions.width - 10; - - let positionScale; - - if (type === 'number') { - positionScale = scaleLinear() - .domain([range.min, range.max]) - .range([yAxisPadding, maxPosition]); - } else { - positionScale = scaleBand() - .domain(range.binLabels) - .range([yAxisPadding, maxPosition]); - } + + const positionScale = makePositionScale('x', type, range); // eslint-disable-next-line @typescript-eslint/no-explicit-any const xAxis = axisBottom(positionScale as any); @@ -635,7 +771,7 @@ export default defineComponent({ .attr('fill', 'currentColor') .attr('font-size', '14px') .attr('font-weight', 'bold') - .attr('x', ((maxPosition - yAxisPadding) / 2) + yAxisPadding) + .attr('x', ((store.state.svgDimensions.width - yAxisPadding) / 2) + yAxisPadding) .attr('y', xAxisPadding - 20); const labelRectPos = (label.node() as SVGTextElement).getBBox(); @@ -652,19 +788,8 @@ export default defineComponent({ if (store.state.columnTypes !== null && layoutVars.value.y !== null) { const type = store.state.columnTypes[layoutVars.value.y]; const range = store.state.attributeRanges[layoutVars.value.y]; - const maxPosition = store.state.svgDimensions.height - xAxisPadding; - - let positionScale; - - if (type === 'number') { - positionScale = scaleLinear() - .domain([range.min, range.max]) - .range([maxPosition, 10]); - } else { - positionScale = scaleBand() - .domain(range.binLabels) - .range([maxPosition, 10]); - } + + const positionScale = makePositionScale('y', type, range); // eslint-disable-next-line @typescript-eslint/no-explicit-any const yAxis = axisLeft(positionScale as any); @@ -688,7 +813,7 @@ export default defineComponent({ .attr('font-size', '14px') .attr('font-weight', 'bold') .attr('text-anchor', 'middle') - .attr('x', ((maxPosition - 10) / 2) + 10) + .attr('x', ((store.state.svgDimensions.height - xAxisPadding - 10) / 2) + 10) .attr('y', yAxisPadding - 20); const labelRectPos = (label.node() as SVGTextElement).getBBox(); @@ -734,6 +859,9 @@ export default defineComponent({ nestedPadding, nodeBarColorScale, glyphFill, + clipRegionSize, + xAxisPadding, + yAxisPadding, }; }, }); @@ -763,6 +891,85 @@ export default defineComponent({ + + + + + low values + + + + + + high values + + + + + + low values + + + + + + high values + + + + diff --git a/src/store/index.ts b/src/store/index.ts index 74e317c2..7ee2d7ab 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -11,7 +11,7 @@ import { import api from '@/api'; import { ColumnTypes, NetworkSpec, UserSpec } from 'multinet'; import { - ScaleBand, scaleBand, ScaleLinear, scaleLinear, scaleOrdinal, scaleSequential, + scaleLinear, scaleOrdinal, scaleSequential, } from 'd3-scale'; import { interpolateBlues, interpolateReds, schemeCategory10 } from 'd3-scale-chromatic'; import { initProvenance, Provenance } from '@visdesignlab/trrack'; @@ -560,84 +560,6 @@ const { } = payload; const otherAxis = axis === 'x' ? 'y' : 'x'; - if (varName !== null) { - // Set node size smaller - commit.setMarkerSize({ markerSize: 10, updateProv: true }); - - // Clear the label variable - commit.setLabelVariable(undefined); - - commit.stopSimulation(); - - if (state.network !== null && state.columnTypes !== null) { - const type = state.columnTypes[varName]; - const range = state.attributeRanges[varName]; - const otherAxisPadding = axis === 'x' ? 80 : 60; - const maxPosition = axis === 'x' ? state.svgDimensions.width - 10 : state.svgDimensions.height - otherAxisPadding - state.markerSize; - - if (type === 'number') { - let positionScale: ScaleLinear; - - if (axis === 'x') { - positionScale = scaleLinear() - .domain([range.min, range.max]) - .range([otherAxisPadding, maxPosition]); - } else { - positionScale = scaleLinear() - .domain([range.min, range.max]) - .range([maxPosition, 10]); - } - - state.network.nodes.forEach((node) => { - // eslint-disable-next-line no-param-reassign - node[axis] = positionScale(node[varName]); - // eslint-disable-next-line no-param-reassign - node[`f${axis}`] = positionScale(node[varName]); - - if (state.layoutVars[otherAxis] === null) { - const otherSvgDimension = axis === 'x' ? state.svgDimensions.height : state.svgDimensions.width; - // eslint-disable-next-line no-param-reassign - node[otherAxis] = otherSvgDimension / 2; - // eslint-disable-next-line no-param-reassign - node[`f${otherAxis}`] = otherSvgDimension / 2; - } - }); - } else { - let positionScale: ScaleBand; - let positionOffset: number; - - if (axis === 'x') { - positionScale = scaleBand() - .domain(range.binLabels) - .range([otherAxisPadding, maxPosition]); - positionOffset = (maxPosition - otherAxisPadding) / ((range.binLabels.length) * 2); - } else { - positionScale = scaleBand() - .domain(range.binLabels) - .range([maxPosition, 10]); - positionOffset = (maxPosition - 10) / ((range.binLabels.length) * 2); - } - - state.network.nodes.forEach((node) => { - // eslint-disable-next-line no-param-reassign - node[axis] = (positionScale(node[varName]) || 0) + positionOffset; - // eslint-disable-next-line no-param-reassign - node[`f${axis}`] = (positionScale(node[varName]) || 0) + positionOffset; - - if (state.layoutVars[otherAxis] === null) { - const otherSvgDimension = axis === 'x' ? state.svgDimensions.height : state.svgDimensions.width; - // eslint-disable-next-line no-param-reassign - node[otherAxis] = otherSvgDimension / 2; - // eslint-disable-next-line no-param-reassign - node[`f${otherAxis}`] = otherSvgDimension / 2; - } - }); - } - } - } else if (state.layoutVars[otherAxis] === null) { - dispatch.releaseNodes(); - } - const updatedLayoutVars = { [axis]: varName, [otherAxis]: state.layoutVars[otherAxis] } as { x: string | null; y: string | null; @@ -650,6 +572,9 @@ const { commit.setMarkerSize({ markerSize: 11, updateProv: false }); dispatch.applyVariableLayout({ varName: state.layoutVars[otherAxis], axis: otherAxis }); + } else if (varName === null && state.layoutVars[otherAxis] === null) { + // If both null, release + dispatch.releaseNodes(); } }, },