From 8268a413fc1d4ef7d0f8b7742702d0a8997acae3 Mon Sep 17 00:00:00 2001 From: Ilfat Galiev Date: Fri, 2 Apr 2021 17:11:54 +0300 Subject: [PATCH] Jitter layout (#476) * Jitter layout * Fix default chart * Add const for radius --- resources/icons/icons_sublayout-jitter.svg | 10 ++ src/app/resources/icons.ts | 4 + src/app/stores/defaults.ts | 4 + src/core/common/utils.ts | 28 +++--- .../plot_segments/region_2d/base.ts | 98 ++++++++++++++++++- .../plot_segments/region_2d/cartesian.ts | 12 ++- .../plot_segments/region_2d/curve.ts | 1 + .../plot_segments/region_2d/polar.ts | 1 + src/core/solver/plugins/index.ts | 1 + src/core/solver/plugins/jitter.ts | 76 ++++++++++++++ src/strings.ts | 3 + 11 files changed, 222 insertions(+), 16 deletions(-) create mode 100644 resources/icons/icons_sublayout-jitter.svg create mode 100644 src/core/solver/plugins/jitter.ts diff --git a/resources/icons/icons_sublayout-jitter.svg b/resources/icons/icons_sublayout-jitter.svg new file mode 100644 index 00000000..a79c3f4c --- /dev/null +++ b/resources/icons/icons_sublayout-jitter.svg @@ -0,0 +1,10 @@ + + icons + + + + + + + + \ No newline at end of file diff --git a/src/app/resources/icons.ts b/src/app/resources/icons.ts index 855714b8..769e5fea 100644 --- a/src/app/resources/icons.ts +++ b/src/app/resources/icons.ts @@ -288,6 +288,10 @@ addSVGIcon( "sublayout/packing", require("resources/icons/icons_sublayout-packing.svg") ); +addSVGIcon( + "sublayout/jitter", + require("resources/icons/icons_sublayout-jitter.svg") +); addSVGIcon("align/left", require("resources/icons/icons_align-left.svg")); addSVGIcon( "align/x-middle", diff --git a/src/app/stores/defaults.ts b/src/app/stores/defaults.ts index 2487bb7f..8beb992b 100644 --- a/src/app/stores/defaults.ts +++ b/src/app/stores/defaults.ts @@ -87,6 +87,10 @@ export function createDefaultPlotSegment( gravityX: 0.1, gravityY: 0.1, }, + jitter: { + vertical: true, + horizontal: true, + }, }, }, } as Specification.PlotSegment; diff --git a/src/core/common/utils.ts b/src/core/common/utils.ts index f81d0486..8862e201 100644 --- a/src/core/common/utils.ts +++ b/src/core/common/utils.ts @@ -464,7 +464,7 @@ export abstract class HashMap { export class MultistringHashMap extends HashMap< string[], ValueType - > { +> { protected separator: string = Math.random().toString(36).substr(2); protected hash(key: string[]): string { return key.join(this.separator); @@ -538,10 +538,10 @@ export function hexToRgb(hex: string): Color { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result ? { - r: parseInt(result[1], 16), - g: parseInt(result[2], 16), - b: parseInt(result[3], 16), - } + r: parseInt(result[1], 16), + g: parseInt(result[2], 16), + b: parseInt(result[3], 16), + } : null; } @@ -570,8 +570,8 @@ export function getSortFunctionByData(values: string[]) { return +aNum < +bNum ? 1 : +a.split("-").pop() < +b.split("-").pop() - ? 1 - : -1; + ? 1 + : -1; } }; } @@ -652,18 +652,18 @@ let formatOptions: FormatLocaleDefinition = { decimal: ".", thousands: ",", grouping: [3], - currency: ["$", ""] + currency: ["$", ""], }; export function getFormatOptions(): FormatLocaleDefinition { return { - ...formatOptions + ...formatOptions, }; } export function setFormatOptions(options: FormatLocaleDefinition) { formatOptions = { - ...options + ...options, }; } @@ -674,7 +674,11 @@ export function getFormat() { export function parseSafe(value: string, defaultValue: any = null) { try { return JSON.parse(value); - } catch(ex) { + } catch (ex) { return defaultValue; } -} \ No newline at end of file +} + +export function getRandom(startRange: number, endRange: number) { + return startRange + Math.random() * (endRange - startRange); +} diff --git a/src/core/prototypes/plot_segments/region_2d/base.ts b/src/core/prototypes/plot_segments/region_2d/base.ts index a67c1b8d..2b846824 100644 --- a/src/core/prototypes/plot_segments/region_2d/base.ts +++ b/src/core/prototypes/plot_segments/region_2d/base.ts @@ -21,7 +21,7 @@ import { PlotSegmentClass } from "../plot_segment"; import { strings } from "./../../../../strings"; export interface Region2DSublayoutOptions extends Specification.AttributeMap { - type: "overlap" | "dodge-x" | "dodge-y" | "grid" | "packing"; + type: "overlap" | "dodge-x" | "dodge-y" | "grid" | "packing" | "jitter"; /** Sublayout alignment (for dodge and grid) */ align: { @@ -50,6 +50,10 @@ export interface Region2DSublayoutOptions extends Specification.AttributeMap { gravityX: number; gravityY: number; }; + jitter: { + vertical: boolean; + horizontal: boolean; + }; } export interface Region2DAttributes extends Specification.AttributeMap { @@ -106,6 +110,7 @@ export interface Region2DConfigurationTerminology { /** Packing force layout */ packing: string; overlap: string; + jitter: string; } export interface Region2DConfigurationIcons { @@ -119,6 +124,7 @@ export interface Region2DConfigurationIcons { dodgeYIcon: string; gridIcon: string; packingIcon: string; + jitterIcon: string; overlapIcon: string; } @@ -232,6 +238,8 @@ export class Region2DConstraintBuilder { public solverContext?: BuildConstraintsContext ) {} + public static defaultJitterPackingRadius = 5; + public getTableContext(): DataflowTable { return this.plotSegment.parent.dataflow.getTable( this.plotSegment.object.table @@ -1093,6 +1101,8 @@ export class Region2DConstraintBuilder { if (context.mode == "x-only" || context.mode == "y-only") { if (props.sublayout.type == "packing") { this.sublayoutPacking(groups, context.mode == "x-only" ? "x" : "y"); + } else if (props.sublayout.type == "jitter") { + this.sublayoutJitter(groups, context.mode == "x-only" ? "x" : "y"); } else { this.fitGroups(groups, axis); } @@ -1116,6 +1126,10 @@ export class Region2DConstraintBuilder { if (props.sublayout.type == "packing") { this.sublayoutPacking(groups); } + // Jitter layout + if (props.sublayout.type == "jitter") { + this.sublayoutJitter(groups); + } } } } @@ -1739,7 +1753,7 @@ export class Region2DConstraintBuilder { } } if (radius == 0) { - radius = 5; + radius = Region2DConstraintBuilder.defaultJitterPackingRadius; } return [ solver.attr(state.attributes, "x"), @@ -1764,6 +1778,60 @@ export class Region2DConstraintBuilder { }); } + public sublayoutJitter(groups: SublayoutGroup[], axisOnly?: "x" | "y") { + const solver = this.solver; + const state = this.plotSegment.state; + const jitterProps = this.plotSegment.object.properties.sublayout.jitter; + + groups.forEach((group) => { + const markStates = group.group.map((index) => state.glyphs[index]); + const { x1, y1, x2, y2 } = group; + + const points = markStates.map((state) => { + let radius = 0; + for (const e of state.marks) { + if (e.attributes.size != null) { + radius = Math.max( + radius, + Math.sqrt((e.attributes.size as number) / Math.PI) + ); + } else { + const w = e.attributes.width as number; + const h = e.attributes.height as number; + if (w != null && h != null) { + radius = Math.max(radius, Math.sqrt(w * w + h * h) / 2); + } + } + } + if (radius == 0) { + radius = Region2DConstraintBuilder.defaultJitterPackingRadius; + } + return [ + solver.attr(state.attributes, "x"), + solver.attr(state.attributes, "y"), + radius, + ] as [Variable, Variable, number]; + }); + solver.addPlugin( + new ConstraintPlugins.JitterPlugin( + solver, + x1, + y1, + x2, + y2, + points, + axisOnly, + jitterProps + ? jitterProps + : { + horizontal: true, + vertical: true, + } + ) + ); + }); + } + public getHandles(): Region2DHandleDescription[] { const state = this.plotSegment.state; const props = this.plotSegment.object.properties; @@ -2094,6 +2162,11 @@ export class Region2DConstraintBuilder { label: terminology.grid, icon: icons.gridIcon, }; + const jitterOption = { + value: "jitter", + label: terminology.jitter, + icon: icons.jitterIcon, + }; const props = this.plotSegment.object.properties; const xMode = props.xData ? props.xData.type : "null"; const yMode = props.yData ? props.yData.type : "null"; @@ -2107,9 +2180,10 @@ export class Region2DConstraintBuilder { dodgeYOption, gridOption, packingOption, + jitterOption, ]; } - return [overlapOption, packingOption]; + return [overlapOption, packingOption, jitterOption]; } public isSublayoutApplicable() { @@ -2275,6 +2349,24 @@ export class Region2DConstraintBuilder { ) ); } + if (type == "jitter") { + extra.push( + m.row( + "Distribution", + m.horizontal( + [0, 1, 1], + m.inputBoolean( + { property: "sublayout", field: ["jitter", "horizontal"] }, + { type: "highlight", icon: "sublayout/dodge-x" } + ), + m.inputBoolean( + { property: "sublayout", field: ["jitter", "vertical"] }, + { type: "highlight", icon: "sublayout/dodge-y" } + ) + ) + ) + ); + } const options = this.applicableSublayoutOptions(); return [ m.sectionHeader("Sub-layout"), diff --git a/src/core/prototypes/plot_segments/region_2d/cartesian.ts b/src/core/prototypes/plot_segments/region_2d/cartesian.ts index ef6f8ff4..88953670 100644 --- a/src/core/prototypes/plot_segments/region_2d/cartesian.ts +++ b/src/core/prototypes/plot_segments/region_2d/cartesian.ts @@ -58,6 +58,7 @@ const icons: Region2DConfigurationIcons = { dodgeYIcon: "sublayout/dodge-y", gridIcon: "sublayout/grid", packingIcon: "sublayout/packing", + jitterIcon: "sublayout/jitter", overlapIcon: "sublayout/overlap", }; @@ -86,7 +87,7 @@ export class CartesianPlotSegment extends PlotSegmentClass< public static defaultMappingValues: Specification.AttributeMap = {}; - public static defaultProperties: Specification.AttributeMap = { + public static defaultProperties: CartesianProperties = { marginX1: 0, marginY1: 0, marginX2: 0, @@ -106,6 +107,15 @@ export class CartesianPlotSegment extends PlotSegmentClass< xCount: null, yCount: null, }, + jitter: { + horizontal: true, + vertical: true, + }, + packing: { + gravityX: 0.1, + gravityY: 0.1, + }, + orderReversed: null, }, }; diff --git a/src/core/prototypes/plot_segments/region_2d/curve.ts b/src/core/prototypes/plot_segments/region_2d/curve.ts index 3e7a95c9..9af7c31f 100644 --- a/src/core/prototypes/plot_segments/region_2d/curve.ts +++ b/src/core/prototypes/plot_segments/region_2d/curve.ts @@ -73,6 +73,7 @@ export let icons: Region2DConfigurationIcons = { dodgeYIcon: "sublayout/dodge-y", gridIcon: "sublayout/grid", packingIcon: "sublayout/packing", + jitterIcon: "sublayout/jitter", overlapIcon: "sublayout/overlap", }; diff --git a/src/core/prototypes/plot_segments/region_2d/polar.ts b/src/core/prototypes/plot_segments/region_2d/polar.ts index 2965401a..fa26aced 100644 --- a/src/core/prototypes/plot_segments/region_2d/polar.ts +++ b/src/core/prototypes/plot_segments/region_2d/polar.ts @@ -86,6 +86,7 @@ export let icons: Region2DConfigurationIcons = { gridIcon: "sublayout/polar-grid", packingIcon: "sublayout/packing", overlapIcon: "sublayout/overlap", + jitterIcon: "sublayout/jitter", }; export class PolarPlotSegment extends PlotSegmentClass< diff --git a/src/core/solver/plugins/index.ts b/src/core/solver/plugins/index.ts index 23427eac..c08451b1 100644 --- a/src/core/solver/plugins/index.ts +++ b/src/core/solver/plugins/index.ts @@ -3,3 +3,4 @@ export { PackingPlugin } from "./packing"; export { PolarCoordinatorPlugin } from "./polar_coordinator"; export { PolarPlotSegmentPlugin } from "./polar_plotsegment"; +export { JitterPlugin } from "./jitter"; diff --git a/src/core/solver/plugins/jitter.ts b/src/core/solver/plugins/jitter.ts new file mode 100644 index 00000000..21e903e2 --- /dev/null +++ b/src/core/solver/plugins/jitter.ts @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +import { forceSimulation, forceCollide, forceX, forceY } from "d3-force"; +import { getRandom } from "../.."; +import { ConstraintPlugin, ConstraintSolver, Variable } from "../abstract"; + +interface NodeType { + x?: number; + y?: number; +} + +export interface JitterPluginOptions { + vertical: boolean; + horizontal: boolean; +} + +export class JitterPlugin extends ConstraintPlugin { + public solver: ConstraintSolver; + public x1: Variable; + public y1: Variable; + public x2: Variable; + public y2: Variable; + public points: Array<[Variable, Variable, number]>; + public xEnable: boolean; + public yEnable: boolean; + public getXYScale: () => { x: number; y: number }; + public options?: JitterPluginOptions; + + constructor( + solver: ConstraintSolver, + x1: Variable, + y1: Variable, + x2: Variable, + y2: Variable, + points: Array<[Variable, Variable, number]>, + axisOnly?: "x" | "y", + options?: JitterPluginOptions + ) { + super(); + this.solver = solver; + this.x1 = x1; + this.y1 = y1; + this.x2 = x2; + this.y2 = y2; + this.points = points; + this.xEnable = axisOnly == null || axisOnly == "x"; + this.yEnable = axisOnly == null || axisOnly == "y"; + this.options = options; + } + + public apply() { + const x1 = this.solver.getValue(this.x1); + const x2 = this.solver.getValue(this.x2); + const y1 = this.solver.getValue(this.y1); + const y2 = this.solver.getValue(this.y2); + const nodes = this.points.map((pt) => { + const x = getRandom(x1, x2); + const y = getRandom(y1, y2); + // Use forceSimulation's default initialization + return { + x, + y, + } as NodeType; + }); + + for (let i = 0; i < nodes.length; i++) { + if (this.options.horizontal) { + this.solver.setValue(this.points[i][0], nodes[i].x); + } + if (this.options.vertical) { + this.solver.setValue(this.points[i][1], nodes[i].y); + } + } + return true; + } +} diff --git a/src/strings.ts b/src/strings.ts index 24451af9..dd1150bc 100644 --- a/src/strings.ts +++ b/src/strings.ts @@ -20,6 +20,7 @@ const cartesianTerminology: Region2DConfigurationTerminology = { gridDirectionX: "X", gridDirectionY: "Y", packing: "Packing", + jitter: "Jitter", overlap: "Overlap", }; @@ -38,6 +39,7 @@ const curveTerminology: Region2DConfigurationTerminology = { gridDirectionX: "Tangent", gridDirectionY: "Normal", packing: "Packing", + jitter: "Jitter", overlap: "Overlap", }; @@ -56,6 +58,7 @@ const polarTerminology: Region2DConfigurationTerminology = { gridDirectionX: "Angular", gridDirectionY: "Radial", packing: "Packing", + jitter: "Jitter", overlap: "Overlap", };