/* * Copyright (C) 2015 The Android Open Source Project * * 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 com.android.tools.chartlib; import com.android.annotations.NonNull; import java.awt.Color; import java.awt.Dimension; import java.awt.Graphics2D; import java.awt.Point; import java.awt.RenderingHints; import java.awt.event.MouseAdapter; import java.awt.event.MouseEvent; import java.awt.geom.Arc2D; import java.awt.geom.Path2D; import java.awt.geom.Point2D; import java.util.ArrayList; import java.util.HashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import javax.swing.tree.TreeNode; /** * Component which renders a * <a href="https://en.wikipedia.org/wiki/Pie_chart#Ring_chart_.2F_Sunburst_chart_.2F_Multilevel_pie_chart"> * sunburst chart</a> that can be unrolled by setting its angle. */ public final class SunburstComponent extends AnimatedComponent { private static final Color[] COLORS = { new Color(0x6baed6), new Color(0xc6dbef), new Color(0xfd8d3c), new Color(0xfdd0a2), new Color(0x74c476), new Color(0xc7e9c0), new Color(0x9e9ac8), new Color(0xdadaeb), new Color(0x969696), new Color(0xd9d9d9), }; private static final Color[] HIGHLIGHTS = { new Color(0x3182bd), new Color(0x9ecae1), new Color(0xe6550d), new Color(0xfdae6b), new Color(0x31a354), new Color(0xa1d99b), new Color(0x756bb1), new Color(0xbcbddc), new Color(0x636363), new Color(0xbdbdbd), }; private ValuedTreeNode mData; private Slice mSlice; private float mGap; private float mStart; private float mFixed; private float mAngle; private float mCurrentAngle; private float mSeparator; private boolean mAutoSize; private float mSliceWidth; private boolean myUseCount; private int mySelectionLevel; private int myZoomLevel; private Slice mySelection; private Slice myZoom; private boolean myLockSelection; private final List<SliceSelectionListener> mListeners; // Calculated values private float mX; private float mY; private float mMaxDepth; private float mMaxSide; private float mCenterX; private float mCenterY; private float mDelta; private Point2D.Float mDirection; private Map<Color, Path2D.Float> mPaths; public SunburstComponent(@NonNull ValuedTreeNode data) { super(30); mData = data; mSlice = new Slice(0.0f); mSliceWidth = 50; mGap = 50; mX = -1; mY = -1; mCurrentAngle = mAngle = 360.0f; mDelta = 0.0f; mFixed = 60.0f; mStart = 180.0f; mSeparator = 1.0f; mySelectionLevel = -1; myLockSelection = false; mPaths = new HashMap<Color, Path2D.Float>(); addMouseListener(new MouseAdapter() { @Override public void mouseClicked(MouseEvent e) { if (e.getClickCount() == 2) { myZoom = mySelection; myZoomLevel = mySelectionLevel; myLockSelection = false; } else { myLockSelection = !myLockSelection && mySelection != null; } } }); mListeners = new LinkedList<SliceSelectionListener>(); } @Override protected void draw(Graphics2D g) { g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); Dimension dim = getSize(); g.setColor(getBackground()); g.fillRect(0, 0, dim.width, dim.height); mPaths.clear(); drawSlice(g, mSlice, 0.0f, 0.0f, 1.0f); for (Map.Entry<Color, Path2D.Float> entry : mPaths.entrySet()) { g.setColor(entry.getKey()); g.fill(entry.getValue()); } } @Override protected void updateData() { Dimension dim = getSize(); mX = dim.width * 0.5f; mY = dim.height * 0.5f; updateArea(); updateStructure(mSlice, mData, false); mCurrentAngle = Math.abs(mCurrentAngle - mAngle) < 0.1f ? mAngle : lerp(mCurrentAngle, mAngle, 0.999f); if (mAutoSize) { float full = Math.min(mMaxDepth, mMaxSide) - mGap - 20; float none = mMaxDepth * 2 - mGap - 20; float factor = mCurrentAngle / 360.0f; float depth = full * factor + none * (1 - factor); mFixed = lerp(mFixed, (float) ((mMaxSide - 20) / Math.PI), 0.999f); float width = depth / getMaxDepth(mSlice); mSliceWidth = lerp(mSliceWidth, width, 0.999f); } // mDelta is the extra radius needed to keep the same length at the fixes radius // (mDelta + mFixed * Rad(mCurrentAngle) == 2PI * mFixed => // mDelta + mFixed == 2PI * mFixed / Rad(mCurrentAngle) => mDelta = 360.0f * mFixed / mCurrentAngle - mFixed; mDirection = new Point2D.Float((float) Math.cos(mStart * Math.PI / 180.0f), -(float) Math.sin(mStart * Math.PI / 180.0f)); mCenterX = mX + (mDelta + mMaxDepth * (360.0f - mCurrentAngle) / 360.0f) * mDirection.x; mCenterY = mY + (mDelta + mMaxDepth * (360.0f - mCurrentAngle) / 360.0f) * mDirection.y; updateSelection(); updateSlice(mSlice, 0, myZoom == null); } private void updateSelection() { Point mouse = getMousePosition(); if (!myLockSelection) { boolean selection = false; if (mouse != null) { float value = 0; float depth = 0; if (mCurrentAngle > 0) { float distance = (float) mouse.distance(mCenterX, mCenterY); depth = (distance - mGap - mDelta) / mSliceWidth; float angle = -(float) Math .toDegrees(Math.atan2(mouse.y - mCenterY, mouse.x - mCenterX)); angle = (angle - mStart - 180.0f + mCurrentAngle * 0.5f) % 360.0f; angle = angle < 0 ? angle + 360.0f : angle; value = angle / mCurrentAngle; } else { float length = (float) (Math.PI * 2.0f * mFixed); // Do the dot product of the vector to the mouse with the unit vector to project to: depth = (mouse.x - mX) * mDirection.x + (mouse.y - mY) * mDirection.y - (mMaxDepth - mGap); value = (mouse.x - mX) * mDirection.y + (mouse.y - mY) * -mDirection.x; // Normalize: depth = -depth / mSliceWidth; value = -value / length + 0.5f; } selection = updateSelectedSlice(mSlice, depth, value, 0); } if (!selection) { mySelectionLevel = -1; if (mySelection != null) { mySelection = null; fireSliceSelected(new SliceSelectionEvent(null)); } } } else { if (mySelection != null && mySelection.node == null) { myLockSelection = false; mySelectionLevel = -1; mySelection = null; fireSliceSelected(new SliceSelectionEvent(null)); } } if (myZoom != null && myZoom.node == null) { resetZoom(); } } public void resetZoom() { myZoom = null; myZoomLevel = -1; } private boolean updateSlice(Slice slice, int level, boolean zoom) { zoom = zoom || slice == myZoom; boolean children = false; for (int i = 0; i < slice.getChildrenCount(); i++) { Slice child = slice.getChild(i); children = updateSlice(child, level + 1, zoom) || children; } zoom = zoom || children; slice.selected = lerp(slice.selected, slice == mySelection ? 1.0f : 0.0f, 0.99f); slice.visible = lerp(slice.visible, zoom ? 1.0f : 0.0f, 0.99f); slice.zoom = lerp(slice.zoom, level < myZoomLevel ? (level == myZoomLevel - 1) ? 0.5f : 0.0f : 1.0f, 0.99f); return zoom; } private boolean updateSelectedSlice(Slice slice, float depth, float value, int level) { if (depth < 0.0f || value < 0.0f || value > 1.0f) { return false; } else if (depth < slice.getDepth()) { mySelectionLevel = level; if (mySelection != slice) { mySelection = slice; fireSliceSelected(new SliceSelectionEvent(slice.node)); } return true; } else { depth -= slice.getDepth(); float total = 0.0f; for (Slice child : slice.getChildren()) { total += child.getValue(); } float current = 0.0f; for (int i = 0; i < slice.getChildrenCount(); i++) { Slice child = slice.getChild(i); float val = child.getValue() / total; if (value < current + val || i == slice.getChildrenCount() - 1) { value = (value - current) / val; return updateSelectedSlice(child, depth, value, level + 1); } current += val; } } return false; } private void fireSliceSelected(SliceSelectionEvent event) { for (SliceSelectionListener listener : mListeners) { listener.valueChanged(event); } } @Override protected void debugDraw(Graphics2D g2d) { addDebugInfo("Total slices: %d", mData.getCount()); addDebugInfo("Paths %d", mPaths.size()); g2d.setColor(Color.GREEN); drawArrow(g2d, mX, mY, mDirection.x, mDirection.y, mMaxDepth, Color.MAGENTA); drawArrow(g2d, mX, mY, mDirection.y, -mDirection.x, mMaxSide, Color.MAGENTA); if (mCurrentAngle == 0) { Path2D.Float fixed = new Path2D.Float(); float length = (float) (Math.PI * 2.0f * mFixed) * 0.5f; fixed.moveTo( mX + (mMaxDepth * (360.0f - mCurrentAngle) / 360.0f - mFixed) * mDirection.x - mDirection.y * length, mY + (mMaxDepth * (360.0f - mCurrentAngle) / 360.0f - mFixed) * mDirection.y + mDirection.x * length); fixed.lineTo( mX + (mMaxDepth * (360.0f - mCurrentAngle) / 360.0f - mFixed) * mDirection.x + mDirection.y * length, mY + (mMaxDepth * (360.0f - mCurrentAngle) / 360.0f - mFixed) * mDirection.y - mDirection.x * length); g2d.draw(fixed); } else { drawMarker(g2d, mCenterX, mCenterY, Color.BLUE); Arc2D.Float fixed = new Arc2D.Float(); fixed.setArcByCenter(mCenterX, mCenterY, mDelta + mFixed, mStart + (360.0f - mCurrentAngle) * 0.5f, mCurrentAngle, Arc2D.OPEN); g2d.draw(fixed); } } private void updateArea() { float angle = (float) Math.toRadians(mStart); float a = (float) Math.cos(angle) * mY; float b = (float) Math.sin(angle) * mX; mMaxDepth = mX * mY / (float) Math.sqrt((a * a) + (b * b)); a = (float) Math.cos(angle + Math.PI * 0.5) * mY; b = (float) Math.sin(angle + Math.PI * 0.5) * mX; mMaxSide = mX * mY / (float) Math.sqrt((a * a) + (b * b)); } float getFraction(ValuedTreeNode node) { TreeNode parent = node.getParent(); assert parent == null || parent instanceof ValuedTreeNode; if (myUseCount) { return parent == null ? 1.0f : (float) node.getCount() / ((ValuedTreeNode) parent).getCount(); } else { return parent == null ? 1.0f : (float) node.getValue() / ((ValuedTreeNode) parent).getValue(); } } static ValuedTreeNode getChildAt(ValuedTreeNode node, int i) { TreeNode child = node.getChildAt(i); assert child instanceof ValuedTreeNode; return (ValuedTreeNode) child; } private float getMaxDepth(Slice slice) { float depth = 0.0f; for (Slice child : slice.getChildren()) { depth = Math.max(depth, getMaxDepth(child)); } return depth + slice.depth; } private boolean updateStructure(Slice slice, ValuedTreeNode node, boolean hasSiblings) { if (node == null) { slice.depth = lerp(slice.depth, hasSiblings ? slice.depth : 0.0f, 0.99f); slice.value = lerp(slice.value, hasSiblings ? 0.0f : slice.value, 0.99f); } else { slice.depth = lerp(slice.depth, node.getParent() == null ? 0.0f : 1.0f, 0.99f); slice.value = lerp(slice.value, getFraction(node), 0.99f); } slice.node = node; int last = -1; int slices = slice.getChildrenCount(); int nodes = node == null ? 0 : node.getChildCount(); for (int i = 0; i < slices; i++) { Slice childSlice = slice.getChild(i); ValuedTreeNode childNode = i < nodes ? getChildAt(node, i) : null; if (updateStructure(childSlice, childNode, nodes > 0)) { last = i; } } // Test neighbours with the same color: int c = slices > 0 ? slice.getChild(0).color : ((slice.color + (int) (Math.random() * COLORS.length - 1) + 1) % COLORS.length); for (int i = slices; i < nodes; i++) { ValuedTreeNode childNode = getChildAt(node, i); Slice childSlice = new Slice(slices > 0 ? 0.0f : getFraction(childNode)); childSlice.color = c; childSlice.depth = slices > 0 ? 1.0f : 0.0f; slice.addChild(childSlice); if (updateStructure(childSlice, childNode, nodes > 0)) { last = i; } } if (last + 1 < slice.getChildrenCount()) { slice.clearSublist(last + 1, slice.getChildrenCount()); } return node != null || (slice.depth > 0.00001f && slice.value > 0.00001f) || last >= 0; } Path2D.Float getPath(Color color) { Path2D.Float path = mPaths.get(color); if (path == null) { path = new Path2D.Float(); mPaths.put(color, path); } return path; } private void drawSlice(Graphics2D g, Slice slice, float depth, float from, float to) { if (slice.getDepth() > 0.0f) { // Optimization for zero width slices Color c = COLORS[slice.color]; float s = slice.selected; Color b = HIGHLIGHTS[slice.color]; c = new Color((int) (b.getRed() * s + c.getRed() * (1 - s)), (int) (b.getGreen() * s + c.getGreen() * (1 - s)), (int) (b.getBlue() * s + c.getBlue() * (1 - s))); Path2D.Float path = getPath(c); if (mCurrentAngle == 0) { float length = (float) (Math.PI * 2.0f * mFixed); float delta = mGap + depth * mSliceWidth - mMaxDepth + mSeparator * 0.5f + slice.getBorder() * mSliceWidth; float up = length * (0.5f - from) - mSeparator * 0.5f; float down = length * (0.5f - to) + mSeparator * 0.5f; float size = mSliceWidth * slice.getDepth() - mSeparator - slice.getBorder() * mSliceWidth * 2.0f; float deltaX = mDirection.x * delta; float deltaY = mDirection.y * delta; float upX = mDirection.y * up; float upY = -mDirection.x * up; float downX = mDirection.y * down; float downY = -mDirection.x * down; float sizeX = mDirection.x * size; float sizeY = mDirection.y * size; if (up > down) { path.moveTo(mX - deltaX + upX, mY - deltaY + upY); path.lineTo(mX - deltaX + upX - sizeX, mY - deltaY + upY - sizeY); path.lineTo(mX - deltaX + downX - sizeX, mY - deltaY + downY - sizeY); path.lineTo(mX - deltaX + downX, mY - deltaY + downY); path.closePath(); } } else { float angle = (360.0f - mCurrentAngle) * 0.5f + mCurrentAngle * from + mStart; float arc = mCurrentAngle * (to - from); float radius = mSliceWidth * depth + mGap + mDelta; float outerLen = (radius + mSliceWidth * slice.getDepth()) * (float) Math.toRadians( arc); if (outerLen < 1) { path = getPath(Color.RED); } float outerRadius = radius + mSliceWidth * slice.getDepth() - mSeparator * 0.5f - slice.getBorder() * mSliceWidth; float innerRadius = radius + mSeparator * 0.5f + slice.getBorder() * mSliceWidth; float outerAngle = (float) Math.toDegrees(Math.asin(mSeparator / outerRadius)); if (outerAngle < arc && outerRadius > innerRadius) { Arc2D.Float outer = new Arc2D.Float(); outer.setArcByCenter(mCenterX, mCenterY, outerRadius, angle + outerAngle * 0.5f, arc - outerAngle, Arc2D.OPEN); path.append(outer, false); float innerAngle = (float) Math.toDegrees(Math.asin(mSeparator / innerRadius)); if (innerAngle < arc) { Arc2D.Float inner = new Arc2D.Float(); inner.setArcByCenter(mCenterX, mCenterY, innerRadius, angle + innerAngle * 0.5f + arc - innerAngle, -(arc - innerAngle), Arc2D.OPEN); path.append(inner, true); } else { float r = (float) (mSeparator * 0.5f / Math .sin(Math.toRadians(arc * 0.5f))); float dx = (float) (Math.cos(Math.toRadians(angle + arc * 0.5f)) * r); float dy = (float) (Math.sin(Math.toRadians(angle + arc * 0.5f)) * r); path.lineTo(mCenterX + dx, mCenterY - dy); } path.lineTo(outer.getStartPoint().getX(), outer.getStartPoint().getY()); } } } float total = 0.0f; for (Slice child : slice.getChildren()) { total += child.getValue(); } float value = 0.0f; for (Slice child : slice.getChildren()) { float childFrom = from + (value / total) * (to - from); float childTo = from + ((value + child.getValue()) / total) * (to - from); drawSlice(g, child, depth + slice.getDepth(), childFrom, childTo); value += child.getValue(); } } public void setGap(float gap) { mGap = gap; } public void setSliceWidth(float sliceWidth) { mSliceWidth = sliceWidth; } public void setAngle(float angle) { mAngle = angle; } public void setStart(float start) { mStart = start; updateArea(); } public void setFixed(int fixed) { mFixed = fixed; } public float getGap() { return mGap; } public float getSliceWidth() { return mSliceWidth; } public float getStart() { return mStart; } public float getFixed() { return mFixed; } public float getAngle() { return mAngle; } public float getSeparator() { return mSeparator; } public void setSeparator(float separator) { mSeparator = separator; } public void setData(ValuedTreeNode data) { mData = data; } public ValuedTreeNode getData() { return mData; } public void setAutoSize(boolean autoSize) { mAutoSize = autoSize; } public void setUseCount(boolean useCount) { myUseCount = useCount; } public void addSelectionListener(SliceSelectionListener listener) { mListeners.add(listener); } public void removeSelectionListener(SliceSelectionListener listener) { mListeners.remove(listener); } static class Slice { private ArrayList<Slice> children = new ArrayList<Slice>(); Slice parent; float value; float depth; int color; float hover; float zoom; float selected; float visible; ValuedTreeNode node; public float getValue() { return value * visible; } public float getBorder() { return selected * depth * 0.05f; } public float getDepth() { return zoom * depth + getBorder() * 2.0f; } public Slice(float value) { this.value = value; this.depth = 1.0f; this.hover = 0.0f; this.selected = 0.0f; this.zoom = 1.0f; this.visible = 1.0f; } public int getChildrenCount() { return children.size(); } public void addChild(Slice child) { children.add(child); child.parent = this; } public Slice getChild(int i) { return children.get(i); } public void clearSublist(int from, int to) { for (int i = from; i < to; i++) { children.get(i).parent = null; } children.subList(from, to).clear(); } public ArrayList<Slice> getChildren() { return children; } } public static class SliceSelectionEvent { private final ValuedTreeNode mNode; public SliceSelectionEvent(ValuedTreeNode node) { mNode = node; } public ValuedTreeNode getNode() { return mNode; } } public interface SliceSelectionListener { void valueChanged(SliceSelectionEvent e); } }