/* * Copyright (c) 2017 by Gerrit Grunwald * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package eu.hansolo.fx.charts; import eu.hansolo.fx.charts.data.PlotItem; import eu.hansolo.fx.charts.event.ItemEventListener; import eu.hansolo.fx.charts.tools.CtxBounds; import eu.hansolo.fx.charts.tools.Helper; import eu.hansolo.fx.charts.tools.Point; import eu.hansolo.fx.geometry.Path; import javafx.beans.DefaultProperty; import javafx.beans.property.BooleanProperty; import javafx.beans.property.BooleanPropertyBase; import javafx.beans.property.DoubleProperty; import javafx.beans.property.DoublePropertyBase; import javafx.beans.property.IntegerProperty; import javafx.beans.property.IntegerPropertyBase; import javafx.beans.property.ObjectProperty; import javafx.beans.property.ObjectPropertyBase; import javafx.collections.FXCollections; import javafx.collections.ListChangeListener; import javafx.collections.ObservableList; import javafx.geometry.VPos; import javafx.scene.Node; import javafx.scene.canvas.Canvas; import javafx.scene.canvas.GraphicsContext; import javafx.scene.control.Tooltip; import javafx.scene.layout.Region; import javafx.scene.paint.Color; import javafx.scene.paint.CycleMethod; import javafx.scene.paint.LinearGradient; import javafx.scene.paint.Stop; import javafx.scene.text.Font; import javafx.scene.text.TextAlignment; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; /** * User: hansolo * Date: 30.11.17 * Time: 04:13 */ @DefaultProperty("children") public class SankeyPlot extends Region { public enum StreamFillMode { COLOR, GRADIENT } private static final double PREFERRED_WIDTH = 600; private static final double PREFERRED_HEIGHT = 400; private static final double MINIMUM_WIDTH = 50; private static final double MINIMUM_HEIGHT = 50; private static final double MAXIMUM_WIDTH = 2048; private static final double MAXIMUM_HEIGHT = 2048; private static final Color DEFAULT_STREAM_COLOR = Color.rgb(164, 164, 164, 0.55); private static final Color DEFAULT_ITEM_COLOR = Color.rgb(164, 164, 164); private static final int DEFAULT_ITEM_WIDTH = 20; private static final int DEFAULT_NODE_GAP = 20; private static final double DEFAULT_OPACITY = 0.55; private double size; private double width; private double height; private Canvas canvas; private GraphicsContext ctx; private ObservableList<PlotItem> items; private ItemEventListener itemListener; private ListChangeListener<PlotItem> itemListListener; private Map<Integer, List<PlotItemData>> itemsPerLevel; private int minLevel; private int maxLevel; private double scaleY; private StreamFillMode _streamFillMode; private ObjectProperty<StreamFillMode> streamFillMode; private Color _streamColor; private ObjectProperty<Color> streamColor; private Color _textColor; private ObjectProperty<Color> textColor; private int _itemWidth; private IntegerProperty itemWidth; private boolean _autoItemWidth; private BooleanProperty autoItemWidth; private int _itemGap; private IntegerProperty itemGap; private boolean _autoItemGap; private BooleanProperty autoItemGap; private int _decimals; private IntegerProperty decimals; private boolean _showFlowDirection; private BooleanProperty showFlowDirection; private boolean _useItemColor; private BooleanProperty useItemColor; private Color _itemColor; private ObjectProperty<Color> itemColor; private double _connectionOpacity; private DoubleProperty connectionOpacity; private Locale _locale; private ObjectProperty<Locale> locale; private String formatString; private Map<Path, String> paths; private Tooltip tooltip; // ******************** Constructors ************************************** public SankeyPlot() { items = FXCollections.observableArrayList(); itemListener = e -> redraw(); itemListListener = c -> { while (c.next()) { if (c.wasAdded()) { c.getAddedSubList().forEach(addedItem -> addedItem.setOnItemEvent(itemListener)); } else if (c.wasRemoved()) { c.getRemoved().forEach(removedItem -> removedItem.removeItemEventListener(itemListener)); } } prepareData(); }; itemsPerLevel = new LinkedHashMap<>(); _streamFillMode = StreamFillMode.COLOR; _streamColor = DEFAULT_STREAM_COLOR; _textColor = Color.BLACK; _itemWidth = DEFAULT_ITEM_WIDTH; _autoItemWidth = true; _itemGap = DEFAULT_NODE_GAP; _autoItemGap = true; _decimals = 0; _showFlowDirection = false; _useItemColor = true; _itemColor = DEFAULT_ITEM_COLOR; _connectionOpacity = DEFAULT_OPACITY; _locale = Locale.getDefault(); formatString = "%." + _decimals + "f"; paths = new LinkedHashMap<>(); initGraphics(); registerListeners(); } // ******************** Initialization ************************************ private void initGraphics() { if (Double.compare(getPrefWidth(), 0.0) <= 0 || Double.compare(getPrefHeight(), 0.0) <= 0 || Double.compare(getWidth(), 0.0) <= 0 || Double.compare(getHeight(), 0.0) <= 0) { if (getPrefWidth() > 0 && getPrefHeight() > 0) { setPrefSize(getPrefWidth(), getPrefHeight()); } else { setPrefSize(PREFERRED_WIDTH, PREFERRED_HEIGHT); } } canvas = new Canvas(PREFERRED_WIDTH, PREFERRED_HEIGHT); ctx = canvas.getGraphicsContext2D(); tooltip = new Tooltip(); tooltip.setAutoHide(true); getChildren().setAll(canvas); } private void registerListeners() { widthProperty().addListener(o -> resize()); heightProperty().addListener(o -> resize()); items.addListener(itemListListener); canvas.setOnMouseClicked(e -> { paths.forEach((path, tooltipText) -> { double eventX = e.getX(); double eventY = e.getY(); if (path.contains(eventX, eventY)) { double tooltipX = eventX + canvas.getScene().getX() + canvas.getScene().getWindow().getX(); double tooltipY = eventY + canvas.getScene().getY() + canvas.getScene().getWindow().getY() - 25; tooltip.setText(tooltipText); tooltip.setX(tooltipX); tooltip.setY(tooltipY); tooltip.show(getScene().getWindow()); } }); }); } // ******************** Methods ******************************************* @Override public void layoutChildren() { super.layoutChildren(); } @Override protected double computeMinWidth(final double HEIGHT) { return MINIMUM_WIDTH; } @Override protected double computeMinHeight(final double WIDTH) { return MINIMUM_HEIGHT; } @Override protected double computePrefWidth(final double HEIGHT) { return super.computePrefWidth(HEIGHT); } @Override protected double computePrefHeight(final double WIDTH) { return super.computePrefHeight(WIDTH); } @Override protected double computeMaxWidth(final double HEIGHT) { return MAXIMUM_WIDTH; } @Override protected double computeMaxHeight(final double WIDTH) { return MAXIMUM_HEIGHT; } @Override public ObservableList<Node> getChildren() { return super.getChildren(); } public void dispose() { items.removeListener(itemListListener); } public List<PlotItem> getItems() { return items; } public void setItems(final PlotItem... ITEMS) { setItems(Arrays.asList(ITEMS)); } public void setItems(final List<PlotItem> ITEMS) { items.setAll(ITEMS); prepareData(); } public void addItem(final PlotItem ITEM) { if (!items.contains(ITEM)) { items.add(ITEM); } prepareData(); } public void removeItem(final PlotItem ITEM) { if (items.contains(ITEM)) { items.remove(ITEM); } prepareData(); } public StreamFillMode getStreamFillMode() { return null == streamFillMode ? _streamFillMode : streamFillMode.get(); } public void setStreamFillMode(final StreamFillMode MODE) { if (null == streamFillMode) { _streamFillMode = MODE; redraw(); } else { streamFillMode.set(MODE); } } public ObjectProperty<StreamFillMode> streamFillModeProperty() { if (null == streamFillMode) { streamFillMode = new ObjectPropertyBase<StreamFillMode>(_streamFillMode) { @Override protected void invalidated() { redraw(); } @Override public Object getBean() { return SankeyPlot.this; } @Override public String getName() { return "streamFillMode"; } }; _streamFillMode = null; } return streamFillMode; } public Color getStreamColor() { return null == streamColor ? _streamColor : streamColor.get(); } public void setStreamColor(final Color COLOR) { if (null == streamColor) { _streamColor = COLOR; redraw(); } else { streamColor.set(COLOR); } } public ObjectProperty<Color> streamColorProperty() { if (null == streamColor) { streamColor = new ObjectPropertyBase<Color>(_streamColor) { @Override protected void invalidated() { redraw(); } @Override public Object getBean() { return SankeyPlot.this; } @Override public String getName() { return "streamColor"; } }; _streamColor = null; } return streamColor; } public Color getTextColor() { return null == textColor ? _textColor : textColor.get(); } public void setTextColor(final Color COLOR) { if (null == textColor) { _textColor = COLOR; redraw(); } else { textColor.set(COLOR); } } public ObjectProperty<Color> textColorProperty() { if (null == textColor) { textColor = new ObjectPropertyBase<Color>(_textColor) { @Override protected void invalidated() { prepareData(); } @Override public Object getBean() { return SankeyPlot.this; } @Override public String getName() { return "textColor"; } }; _textColor = null; } return textColor; } public int getItemWidth() { return null == itemWidth ? _itemWidth : itemWidth.get(); } public void setItemWidth(final int WIDTH) { if (null == itemWidth) { _itemWidth = Helper.clamp(2, 50, WIDTH); prepareData(); } else { itemWidth.set(WIDTH); } } public IntegerProperty itemWidthProperty() { if (null == itemWidth) { itemWidth = new IntegerPropertyBase(_itemWidth) { @Override protected void invalidated() { set(Helper.clamp(2, 50, get())); prepareData(); } @Override public Object getBean() { return SankeyPlot.this; } @Override public String getName() { return "itemWidth"; } }; } return itemWidth; } public boolean isAutoItemWidth() { return null == autoItemWidth ? _autoItemWidth : autoItemWidth.get(); } public void setAutoItemWidth(final boolean AUTO) { if (null == autoItemWidth) { _autoItemWidth = AUTO; prepareData(); } else { autoItemWidth.set(AUTO); } } public BooleanProperty autoItemWidthProperty() { if (null == autoItemWidth) { autoItemWidth = new BooleanPropertyBase(_autoItemWidth) { @Override protected void invalidated() { prepareData(); } @Override public Object getBean() { return SankeyPlot.this; } @Override public String getName() { return "autoItemWidth"; } }; } return autoItemWidth; } public int getItemGap() { return null == itemGap ? _itemGap : itemGap.get(); } public void setItemGap(final int GAP) { if (null == itemGap) { _itemGap = Helper.clamp(0, 100, GAP); prepareData(); } else { itemGap.set(GAP); } } public IntegerProperty itemGapProperty() { if (null == itemGap) { itemGap = new IntegerPropertyBase(_itemGap) { @Override protected void invalidated() { set(Helper.clamp(0, 100, get())); prepareData(); } @Override public Object getBean() { return SankeyPlot.this; } @Override public String getName() { return "itemGap"; } }; } return itemGap; } public boolean isAutoItemGap() { return null == autoItemGap ? _autoItemGap : autoItemGap.get(); } public void setAutoItemGap(final boolean AUTO) { if (null == autoItemGap) { _autoItemGap = AUTO; prepareData(); } else { autoItemGap.set(AUTO); } } public BooleanProperty autoItemGapProperty() { if (null == autoItemGap) { autoItemGap = new BooleanPropertyBase(_autoItemGap) { @Override protected void invalidated() { prepareData(); } @Override public Object getBean() { return SankeyPlot.this; } @Override public String getName() { return "autoItemGap"; } }; } return autoItemGap; } public int getDecimals() { return null == decimals ? _decimals : decimals.get(); } public void setDecimals(final int DECIMALS) { if (null == decimals) { _decimals = Helper.clamp(0, 6, DECIMALS); formatString = new StringBuilder("%.").append(getDecimals()).append("f").toString(); redraw(); } else { decimals.set(DECIMALS); } } public IntegerProperty decimalsProperty() { if (null == decimals) { decimals = new IntegerPropertyBase(_decimals) { @Override protected void invalidated() { set(Helper.clamp(0, 6, get())); formatString = new StringBuilder("%.").append(get()).append("f").toString(); redraw(); } @Override public Object getBean() { return SankeyPlot.this; } @Override public String getName() { return "decimals"; } }; } return decimals; } public boolean getShowFlowDirection() { return null == showFlowDirection ? _showFlowDirection : showFlowDirection.get(); } public void setShowFlowDirection(final boolean SHOW) { if (null == showFlowDirection) { _showFlowDirection = SHOW; redraw(); } else { showFlowDirection.set(SHOW); } } public BooleanProperty showFlowDirectionProperty() { if (null == showFlowDirection) { showFlowDirection = new BooleanPropertyBase(_showFlowDirection) { @Override protected void invalidated() { redraw(); } @Override public Object getBean() { return SankeyPlot.this; } @Override public String getName() { return "showFlowDirection"; } }; } return showFlowDirection; } public boolean getUseItemColor() { return null == useItemColor ? _useItemColor : useItemColor.get(); } public void setUseItemColor(final boolean USE) { if (null == useItemColor) { _useItemColor = USE; redraw(); } else { useItemColor.set(USE); } } public BooleanProperty useItemColorProperty() { if (null == useItemColor) { useItemColor = new BooleanPropertyBase(_useItemColor) { @Override protected void invalidated() { redraw(); } @Override public Object getBean() { return SankeyPlot.this; } @Override public String getName() { return "useItemColor"; } }; } return useItemColor; } public Color getItemColor() { return null == itemColor ? _itemColor : itemColor.get(); } public void setItemColor(final Color COLOR) { if (null == itemColor) { _itemColor = COLOR; redraw(); } else { itemColor.set(COLOR); } } public ObjectProperty<Color> itemColorProperty() { if (null == itemColor) { itemColor = new ObjectPropertyBase<Color>(_itemColor) { @Override protected void invalidated() { redraw(); } @Override public Object getBean() { return SankeyPlot.this; } @Override public String getName() { return "itemColor"; } }; } return itemColor; } public double getConnectionOpacity() { return null == connectionOpacity ? _connectionOpacity : connectionOpacity.get(); } public void setConnectionOpacity(final double OPACITY) { if (null == connectionOpacity) { _connectionOpacity = Helper.clamp(0.1, 1.0, OPACITY); redraw(); } else { connectionOpacity.set(OPACITY); } } public DoubleProperty connectionOpacityProperty() { if (null == connectionOpacity) { connectionOpacity = new DoublePropertyBase(_connectionOpacity) { @Override protected void invalidated() { set(Helper.clamp(0.1, 1.0, get())); redraw(); } @Override public Object getBean() { return SankeyPlot.this; } @Override public String getName() { return "connectionOpacity"; } }; } return connectionOpacity; } public Locale getLocale() { return null == locale ? _locale : locale.get(); } public void setLocale(final Locale LOCALE) { if (null == locale) { _locale = LOCALE; prepareData(); } else { locale.set(LOCALE); } } public ObjectProperty<Locale> localeProperty() { if (null == locale) { locale = new ObjectPropertyBase<Locale>(_locale) { @Override protected void invalidated() { prepareData(); } @Override public Object getBean() { return SankeyPlot.this; } @Override public String getName() { return "locale"; } }; } _locale = null; return locale; } public List<PlotItem> getItemsWithOnlyOutgoing() { //return getItems().stream().filter(PlotItem::hasOutgoing).filter(not(PlotItem::hasIncoming)).collect(Collectors.toList()); return getItems().stream().filter(item -> item.hasOutgoing() && !item.hasIncoming()).collect(Collectors.toList()); } public List<PlotItem> getItemsWithOnlyIncoming() { //return getItems().stream().filter(not(PlotItem::hasOutgoing)).filter(PlotItem::hasIncoming).collect(Collectors.toList()); return getItems().stream().filter(item -> !item.hasOutgoing() && item.hasIncoming()).collect(Collectors.toList()); } public List<PlotItem> getItemsWithInAndOutgoing() { return getItems().stream().filter(PlotItem::hasOutgoing).filter(PlotItem::hasIncoming).collect(Collectors.toList()); } private void sortIncomingByOutgoingOrder(final List<PlotItem> INCOMING, final List<PlotItem> OUTGOING) { Collections.sort(INCOMING, Comparator.comparing(item -> OUTGOING.indexOf(item))); } private void sortOutgoingByNextLevelIncomingOrder(final List<PlotItem> OUTGOING, final List<PlotItem> INCOMING) { Collections.sort(OUTGOING, Comparator.comparing(item -> INCOMING.indexOf(item))); } private double getSumFromDataItems(final List<PlotItemData> DATA_ITEMS) { return DATA_ITEMS.stream().map(plotItemData -> plotItemData.getPlotItem()).mapToDouble(PlotItem::getMaxSum).sum(); } private void prepareData() { // Split all items to levels itemsPerLevel.clear(); items.forEach(item -> { int level = item.getLevel(); if (itemsPerLevel.keySet().contains(level)) { itemsPerLevel.get(level).add(new PlotItemData(item)); } else { itemsPerLevel.put(level, new ArrayList<>()); itemsPerLevel.get(level).add(new PlotItemData(item)); } }); // Get min- and max level minLevel = itemsPerLevel.keySet().stream().mapToInt(Integer::intValue).min().getAsInt(); maxLevel = itemsPerLevel.keySet().stream().mapToInt(Integer::intValue).max().getAsInt(); // Move items with no incoming streams to correct level dependent on level of their outgoing items List<PlotItemData> minLevelItems = itemsPerLevel.get(minLevel); Map<PlotItemData, Integer> itemsToMove = new LinkedHashMap<>(); for (PlotItemData plotItemData : minLevelItems) { int minLevelOfOutgoingItems = maxLevel; int maxLevelOfOutgoingItems = minLevel; for(PlotItem plotItem : plotItemData.getPlotItem().getOutgoing().keySet()) { int levelOfItem = plotItem.getLevel(); minLevelOfOutgoingItems = Math.min(minLevelOfOutgoingItems, levelOfItem); maxLevelOfOutgoingItems = Math.max(maxLevelOfOutgoingItems, levelOfItem); } if (minLevelOfOutgoingItems > minLevel + 1) { itemsToMove.put(plotItemData, minLevelOfOutgoingItems - 1); } } itemsToMove.forEach((itemData, newLevel) -> { minLevelItems.remove(itemData); //itemsPerLevel.get(newLevel).add(itemData); itemsPerLevel.get(newLevel).add(0, itemData); }); // Reverse items in at each level itemsPerLevel.forEach((level, items) -> Collections.reverse(items)); // Sort outgoing and incoming items at each level dependent on the order of former and next level items for (int level = minLevel ; level <= maxLevel ; level++) { List<PlotItemData> itemData = itemsPerLevel.get(level); if (level < maxLevel) { List<PlotItem> nextLevelItems = itemsPerLevel.get(level + 1).stream().map(plotItemData -> plotItemData.getPlotItem()).collect(Collectors.toList()); itemData.forEach(id -> id.getPlotItem().sortOutgoingByGivenList(nextLevelItems)); } if (level > minLevel) { List<PlotItem> formerLevelItems = itemsPerLevel.get(level - 1).stream().map(plotItemData -> plotItemData.getPlotItem()).collect(Collectors.toList()); itemData.forEach(id -> id.getPlotItem().sortIncomingByGivenList(formerLevelItems)); } } // Get max no of items, max sum of values etc. int maxNoOfItemsAtLevel = 0; double maxSumOfItemsAtLevel = 0; for (int i = minLevel ; i <= maxLevel ; i++) { List<PlotItemData> items = itemsPerLevel.get(i); maxNoOfItemsAtLevel = Math.max(maxNoOfItemsAtLevel, items.size()); maxSumOfItemsAtLevel = Math.max(maxSumOfItemsAtLevel, getSumFromDataItems(items)); } // Define drawing parameters double itemWidth = isAutoItemWidth() ? size * 0.025 : getItemWidth(); double verticalGap = isAutoItemGap() ? size * 0.025 : getItemGap(); double textGap = size * 0.0125; double maxSum = maxSumOfItemsAtLevel; int maxItems = maxNoOfItemsAtLevel; double horizontalGap = (width - itemWidth) / maxLevel; scaleY = (height - (maxItems - 1) * verticalGap) / maxSum; double spacerX; double spacerY; for (int level = minLevel ; level <= maxLevel ; level++) { spacerY = 0; spacerX = horizontalGap * level; for (PlotItemData itemData : itemsPerLevel.get(level)) { PlotItem item = itemData.getPlotItem(); double itemHeight = item.getMaxSum() * scaleY; double textOffsetX = level < maxLevel ? textGap + itemWidth : -textGap; itemData.setBounds(spacerX , (height - itemHeight) - spacerY, itemWidth, itemHeight); itemData.setTextPoint(spacerX + textOffsetX, (height - itemHeight * 0.5) - spacerY); spacerY += itemHeight + verticalGap; } } createPaths(); redraw(); } // ******************** Resizing ****************************************** private void resize() { width = getWidth() - getInsets().getLeft() - getInsets().getRight(); height = getHeight() - getInsets().getTop() - getInsets().getBottom(); size = width < height ? width : height; if (width > 0 && height > 0) { canvas.setWidth(width); canvas.setHeight(height); canvas.relocate((getWidth() - width) * 0.5, (getHeight() - height) * 0.5); ctx.setTextBaseline(VPos.CENTER); ctx.setFont(Font.font(Helper.clamp(8, 24, size * 0.025))); prepareData(); } } private void createPaths() { paths.clear(); boolean showFlowDirection = getShowFlowDirection(); double showDirectionOffsetX = size * 0.01875; double connectionOpacity = getConnectionOpacity(); // Draw bezier curves between items for (int level = minLevel ; level <= maxLevel ; level++) { List<PlotItemData> itemDataInLevel = itemsPerLevel.get(level); int nextLevel = level + 1; // Go through all item data of the current level for (PlotItemData itemData : itemDataInLevel) { PlotItem item = itemData.getPlotItem(); CtxBounds bounds = itemData.getBounds(); // Outgoing if (level < maxLevel) { List<PlotItemData> nextLevelItemDataList = itemsPerLevel.get(nextLevel); for (PlotItem outgoingItem : item.getOutgoing().keySet()) { Optional<PlotItemData> targetItemDataOptional = nextLevelItemDataList.stream().filter(id -> id.getPlotItem().equals(outgoingItem)).findFirst(); if (!targetItemDataOptional.isPresent()) { continue; } PlotItemData targetItemData = targetItemDataOptional.get(); CtxBounds targetItemBounds = targetItemData.getBounds(); PlotItem targetItem = targetItemData.getPlotItem(); // Calculate y start position in target item dependent on item index in target incoming double targetIncomingOffsetY = 0; for (PlotItem incomingItem : targetItem.getIncoming().keySet()) { if (incomingItem.equals(item)) { break; } targetIncomingOffsetY += targetItem.getIncoming().get(incomingItem) * scaleY; } // Calculate the offset in x direction for the bezier curve control points double ctrlPointOffsetX = (targetItemBounds.getMinX() - bounds.getMaxX()) * 0.25; // Calculate the value of the current item in y direction double outgoingValue = item.getOutgoing().get(outgoingItem); double scaledValueY = outgoingValue * scaleY; // Create Path Path path = new Path(); // Set Gradient from current item to outgoing items if (StreamFillMode.COLOR == getStreamFillMode()) { path.setFill(getStreamColor()); } else { path.setFill(new LinearGradient(0, 0, 1, 0, true, CycleMethod.NO_CYCLE, new Stop(0, Helper.getColorWithOpacity(item.getFill(), connectionOpacity)), new Stop(1, Helper.getColorWithOpacity(outgoingItem.getFill(), connectionOpacity)))); } // Draw the bezier curve path.moveTo(bounds.getMaxX(), bounds.getMinY() + itemData.getOutgoingOffsetY()); if (showFlowDirection) { path.bezierCurveTo(bounds.getMaxX() + ctrlPointOffsetX, bounds.getMinY() + itemData.getOutgoingOffsetY(), targetItemBounds.getMinX() - ctrlPointOffsetX, targetItemBounds.getMinY() + targetIncomingOffsetY, targetItemBounds.getMinX() - showDirectionOffsetX, targetItemBounds.getMinY() + targetIncomingOffsetY); path.lineTo(targetItemBounds.getMinX(), targetItemBounds.getMinY() + targetIncomingOffsetY + scaledValueY * 0.5); path.lineTo(targetItemBounds.getMinX() - showDirectionOffsetX, targetItemBounds.getMinY() + targetIncomingOffsetY + scaledValueY); } else { path.bezierCurveTo(bounds.getMaxX() + ctrlPointOffsetX, bounds.getMinY() + itemData.getOutgoingOffsetY(), targetItemBounds.getMinX() - ctrlPointOffsetX, targetItemBounds.getMinY() + targetIncomingOffsetY, targetItemBounds.getMinX(), targetItemBounds.getMinY() + targetIncomingOffsetY); path.lineTo(targetItemBounds.getMinX(), targetItemBounds.getMinY() + targetIncomingOffsetY + scaledValueY); } itemData.addToOutgoingOffset(scaledValueY); targetItemData.addToIncomingOffset(scaledValueY); path.bezierCurveTo(targetItemBounds.getMinX() - ctrlPointOffsetX, targetItemBounds.getMinY() + targetIncomingOffsetY + scaledValueY, bounds.getMaxX() + ctrlPointOffsetX, bounds.getMinY() + itemData.getOutgoingOffsetY(), bounds.getMaxX(), bounds.getMinY() + itemData.getOutgoingOffsetY()); path.lineTo(bounds.getMaxX(), bounds.getMinY() + itemData.getOutgoingOffsetY()); path.closePath(); String tooltipText = new StringBuilder().append(item.getName()) .append(" -> ") .append(targetItem.getName()) .append(" ") .append(String.format(getLocale(), formatString, outgoingValue)) .toString(); paths.put(path, tooltipText); } } } } } private void redraw() { ctx.clearRect(0, 0, width, height); paths.forEach((path, plotItem) -> { path.draw(ctx, true, false); }); boolean useItemColor = getUseItemColor(); Color itemColor = getItemColor(); Color textColor = getTextColor(); // Draw bezier curves between items for (int level = minLevel ; level <= maxLevel ; level++) { List<PlotItemData> itemDataInLevel = itemsPerLevel.get(level); // Go through all item data of the current level for (PlotItemData itemData : itemDataInLevel) { PlotItem item = itemData.getPlotItem(); CtxBounds bounds = itemData.getBounds(); // Draw item boxes with their labels ctx.setFill(useItemColor ? item.getFill() : itemColor); ctx.fillRect(bounds.getX(), bounds.getY(), bounds.getWidth(), bounds.getHeight()); ctx.setFill(textColor); ctx.setTextAlign(level == maxLevel ? TextAlignment.RIGHT : TextAlignment.LEFT); ctx.fillText(item.getName(), itemData.getTextPoint().getX(), itemData.getTextPoint().getY()); } } } // ******************** Inner Classes ************************************* private class PlotItemData { private PlotItem plotItem; private CtxBounds bounds; // bounds of the item rectangle private Point textPoint; // point where text will be drawn private double incomingOffsetY; // offset in y direction of already added incoming bezier curves private double outgoingOffsetY; // offset in y direction of already added outgoing bezier curves private double value; // ******************** Constructors ********************************** public PlotItemData(final PlotItem ITEM) { plotItem = ITEM; bounds = new CtxBounds(); textPoint = new Point(); incomingOffsetY = 0; outgoingOffsetY = 0; value = 0; } // ******************** Methods ******************************************* public PlotItem getPlotItem() { return plotItem; } public CtxBounds getBounds() { return bounds; } public void setBounds(final double X, final double Y, final double WIDTH, final double HEIGHT) { bounds.set(X, Y, WIDTH, HEIGHT); } public Point getTextPoint() { return textPoint; } public void setTextPoint(final double X, final double Y) { textPoint.set(X, Y); } public double getIncomingOffsetY() { return incomingOffsetY; } public void setIncomingOffsetY(final double OFFSET) { incomingOffsetY = OFFSET; } public void addToIncomingOffset(final double ADD) { incomingOffsetY += ADD; } public void resetIncomingOffset() { incomingOffsetY = 0; } public double getOutgoingOffsetY() { return outgoingOffsetY; } public void setOutgoingOffsetY(final double OFFSET) { outgoingOffsetY = OFFSET; } public void addToOutgoingOffset(final double ADD) { outgoingOffsetY += ADD; } public void resetOutgoingOffset() { outgoingOffsetY = 0; } public double getValue() { return value; } public void setValue(final double VALUE) { value = VALUE; } } }