/*
* Copyright (C) 2014 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.idea.wizard;
import com.android.sdklib.repository.FullRevision;
import com.google.common.collect.Lists;
import com.google.gson.*;
import com.google.gson.reflect.TypeToken;
import com.intellij.openapi.diagnostic.Logger;
import com.intellij.ui.JBColor;
import com.intellij.util.ResourceUtil;
import com.intellij.util.ui.GraphicsUtil;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import javax.swing.*;
import java.awt.*;
import java.awt.event.MouseAdapter;
import java.awt.event.MouseEvent;
import java.io.IOException;
import java.lang.reflect.Type;
import java.text.DecimalFormat;
import java.util.ArrayList;
import java.util.List;
/**
* Chart of distributions
*/
public class DistributionChartComponent extends JPanel {
private static final Logger LOG = Logger.getInstance(DistributionChartComponent.class);
// Because this text overlays colored components, it must stay white/gray, and does not change for dark themes.
private static final Color TEXT_COLOR = new Color(0xFEFEFE);
private static final Color API_LEVEL_COLOR = new Color(0, 0, 0, 77);
private static final int INTER_SECTION_SPACING = 1;
private static final double MIN_PERCENTAGE_HEIGHT = 0.06;
private static final double EXPANSION_ON_SELECTION = 1.063882064;
private static final double RIGHT_GUTTER_PERCENTAGE = 0.209708738;
private static final int TOP_PADDING = 40;
private static final int NAME_OFFSET = 50;
private static final int MIN_API_FONT_SIZE = 18;
private static final int MAX_API_FONT_SIZE = 45;
private static final int API_OFFSET = 120;
private static final int NUMBER_OFFSET = 10;
private static Font MEDIUM_WEIGHT_FONT;
private static Font REGULAR_WEIGHT_FONT;
private static Font VERSION_NAME_FONT;
private static Font VERSION_NUMBER_FONT;
private static Font TITLE_FONT;
// These colors do not change for dark vs light theme.
// These colors come from our UX team and they are very adamant
// about their exactness. Hardcoding them is a pain.
private static final Color[] RECT_COLORS = new Color[] {
new Color(0xcbdfcb),
new Color(0x7dc691),
new Color(0x92b2b7),
new Color(0xdeba40),
new Color(0xe55d5f),
new Color(0x6ec0d2),
new Color(0xd88d63),
new Color(0xff9229),
new Color(0xeabd2d)
};
private static List<Distribution> ourDistributions;
private int[] myCurrentBottoms;
private Distribution mySelectedDistribution;
private DistributionSelectionChangedListener myListener;
public void init() {
addMouseListener(new MouseAdapter() {
@Override
public void mouseClicked(MouseEvent mouseEvent) {
int y = mouseEvent.getY();
int i = 0;
while (i < myCurrentBottoms.length && y > myCurrentBottoms[i]) {
++i;
}
if (i < myCurrentBottoms.length) {
mySelectedDistribution = ourDistributions.get(i);
if (myListener != null) {
myListener.onDistributionSelected(mySelectedDistribution);
}
repaint();
}
}
});
if (ourDistributions == null) {
try {
String jsonString = ResourceUtil.loadText(ResourceUtil.getResource(this.getClass(), "wizardData", "distributions.json"));
ourDistributions = loadDistributionsFromJson(jsonString);
}
catch (IOException e) {
throw new RuntimeException(e);
}
}
loadFonts();
}
private static void loadFonts() {
if (MEDIUM_WEIGHT_FONT == null) {
REGULAR_WEIGHT_FONT = new Font("Sans", Font.PLAIN, 12);
MEDIUM_WEIGHT_FONT = new Font("Sans", Font.BOLD, 12);
VERSION_NAME_FONT = REGULAR_WEIGHT_FONT.deriveFont((float)16.0);
VERSION_NUMBER_FONT = REGULAR_WEIGHT_FONT.deriveFont((float)20.0);
TITLE_FONT = MEDIUM_WEIGHT_FONT.deriveFont((float)16.0);
}
}
@Nullable
private static List<Distribution> loadDistributionsFromJson(String jsonString) {
Type fullRevisionType = new TypeToken<FullRevision>(){}.getType();
GsonBuilder gsonBuilder = new GsonBuilder()
.registerTypeAdapter(fullRevisionType, new JsonDeserializer<FullRevision>() {
@Override
public FullRevision deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) throws JsonParseException {
return FullRevision.parseRevision(json.getAsString());
}
});
Gson gson = gsonBuilder.create();
Type listType = new TypeToken<ArrayList<Distribution>>() {}.getType();
try {
return gson.fromJson(jsonString, listType);
} catch (JsonParseException e) {
LOG.error(e);
}
return null;
}
public void registerDistributionSelectionChangedListener(@NotNull DistributionSelectionChangedListener listener) {
myListener = listener;
}
@Override
public Dimension getMinimumSize() {
return new Dimension(300, 300);
}
@Override
public void paintComponent(Graphics g) {
GraphicsUtil.setupAntialiasing(g);
GraphicsUtil.setupAAPainting(g);
super.paintComponent(g);
if (myCurrentBottoms == null) {
myCurrentBottoms = new int[ourDistributions.size()];
}
// Draw the proportioned rectangles
int startY = TOP_PADDING;
int totalWidth = getBounds().width;
int rightGutter = (int)Math.round(totalWidth * RIGHT_GUTTER_PERCENTAGE);
int width = totalWidth - rightGutter;
int normalBoxSize = (int)Math.round((float)width/EXPANSION_ON_SELECTION);
int leftGutter = (width - normalBoxSize) / 2;
// Measure our fonts
FontMetrics titleMetrics = g.getFontMetrics(TITLE_FONT);
int titleHeight = titleMetrics.getHeight();
FontMetrics versionNumberMetrics = g.getFontMetrics(VERSION_NUMBER_FONT);
int halfVersionNumberHeight = (versionNumberMetrics.getHeight() - versionNumberMetrics.getDescent()) / 2;
FontMetrics versionNameMetrics = g.getFontMetrics(VERSION_NAME_FONT);
int halfVersionNameHeight = (versionNameMetrics.getHeight() - versionNameMetrics.getDescent()) / 2;
// Draw the titles
g.setFont(TITLE_FONT);
g.drawString("API Level".toUpperCase(), leftGutter, titleHeight);
String distributionTitle = "Distribution".toUpperCase();
String accumulativeTitle = "Cumulative".toUpperCase();
g.drawString(accumulativeTitle, totalWidth - titleMetrics.stringWidth(accumulativeTitle), titleHeight);
g.drawString(distributionTitle, totalWidth - titleMetrics.stringWidth(distributionTitle), titleHeight * 2);
// We want a padding in between every element
int heightToDistribute = getBounds().height - INTER_SECTION_SPACING * (ourDistributions.size() - 1) - TOP_PADDING;
// Keep track of how much of the distribution we've covered so far
double percentageSum = 0;
int smallItemCount = 0;
for (Distribution d : ourDistributions) {
if (d.distributionPercentage < MIN_PERCENTAGE_HEIGHT) {
smallItemCount++;
}
}
heightToDistribute -= (int)Math.round(smallItemCount * MIN_PERCENTAGE_HEIGHT * heightToDistribute);
int i = 0;
for (Distribution d : ourDistributions) {
if (d.color == null) {
d.color = RECT_COLORS[i % RECT_COLORS.length];
}
// Draw the colored rectangle
g.setColor(d.color);
double effectivePercentage = Math.max(d.distributionPercentage, MIN_PERCENTAGE_HEIGHT);
int calculatedHeight = (int)Math.round(effectivePercentage * heightToDistribute);
int bottom = startY + calculatedHeight;
if (d.equals(mySelectedDistribution)) {
g.fillRect(0, bottom - calculatedHeight, width, calculatedHeight);
} else {
g.fillRect(leftGutter, bottom - calculatedHeight, normalBoxSize, calculatedHeight);
}
// Size our fonts according to the rectangle size
Font apiLevelFont = REGULAR_WEIGHT_FONT.deriveFont(logistic(effectivePercentage, MIN_API_FONT_SIZE, MAX_API_FONT_SIZE));
// Measure our font heights so we can center text
FontMetrics apiLevelMetrics = g.getFontMetrics(apiLevelFont);
int halfApiFontHeight = (apiLevelMetrics.getHeight() - apiLevelMetrics.getDescent()) / 2;
int currentMidY = startY + calculatedHeight/2;
// Write the name
g.setColor(TEXT_COLOR);
g.setFont(VERSION_NAME_FONT);
myCurrentBottoms[i] = bottom;
g.drawString(d.name, leftGutter + NAME_OFFSET, currentMidY + halfVersionNameHeight);
// Write the version number
g.setColor(API_LEVEL_COLOR);
g.setFont(VERSION_NUMBER_FONT);
String versionString = d.version.toString().substring(0, 3);
g.drawString(versionString, leftGutter + NUMBER_OFFSET, currentMidY + halfVersionNumberHeight);
// Write the API level
g.setFont(apiLevelFont);
g.drawString(Integer.toString(d.apiLevel), width - API_OFFSET, currentMidY + halfApiFontHeight);
// Write the supported distribution
percentageSum += d.distributionPercentage;
// Write the percentage sum
if (i < ourDistributions.size() - 1) {
g.setColor(JBColor.foreground());
g.setFont(VERSION_NUMBER_FONT);
String percentageString = new DecimalFormat("0.0%").format(.999 - percentageSum);
int percentStringWidth = versionNumberMetrics.stringWidth(percentageString);
g.drawString(percentageString, totalWidth - percentStringWidth - 2, bottom - 2);
g.setColor(JBColor.darkGray);
g.drawLine(leftGutter + normalBoxSize, startY + calculatedHeight, totalWidth, startY + calculatedHeight);
}
startY += calculatedHeight + INTER_SECTION_SPACING;
i++;
}
}
/**
* Get an S-Curve value between min and max
* @param normalizedValue a value between 0 and 1
* @return an integer between the given min and max value
*/
private static float logistic(double normalizedValue, int min, int max) {
double t = normalizedValue * 1;
double result = (max * min * Math.exp(min * t)) / (max + min * Math.exp(min * t));
return (float)result;
}
protected static class Distribution implements Comparable<Distribution> {
public static class TextBlock {
public String title;
public String body;
}
public int apiLevel;
public FullRevision version;
public double distributionPercentage;
public String name;
public Color color;
public String description;
public String url;
public List<TextBlock> descriptionBlocks;
private Distribution() {
// Private default for json conversion
}
@Override
public int compareTo(Distribution other) {
return Integer.valueOf(apiLevel).compareTo(other.apiLevel);
}
}
public interface DistributionSelectionChangedListener {
void onDistributionSelected(Distribution d);
}
public double getSupportedDistributionForApiLevel(int apiLevel) {
double unsupportedSum = 0;
for (Distribution d : ourDistributions) {
if (d.apiLevel >= apiLevel) {
break;
}
unsupportedSum += d.distributionPercentage;
}
return 1 - unsupportedSum;
}
}