Thursday, February 11, 2016

Create your own Medusa Gauge Skin

Hi there,

Today I will show you how you can easily create your own skin for the Medusa gauge. Therefore the first thing we need to do is to have an idea of what we would like to visualize. For this post I've decided to create something like a LED bar graph that you might know from your home stereo equalizer. Usually it looks similar to this...at least when I was young :)



So the idea is to create a bar of rectangles or circles that will be filled dependent on the current value. And as you can see on the image above it would be nice to have different colors for values in range (green), warning (yellow) and over the limit (red).
I know I mentioned it couple of times but if you work with graphics (which we do here) it's always good to be able to use a vector drawing program to create a prototype.
It is no secret that I really love Adobe Fireworks because it has all vector features I need plus some bitmap capabilities and it's not confusing like Illustrator with all it's features.
I decided to go with circular led's and my prototype looks like follows...



I use the following values for my prototype

  • ledSize     = 10 px (width * 0.5)
  • ledSpacer = 1px (space between led's, width * 0.05)
  • ledBorder = 5 px (space on top, left, bottom and right side of led stripe, width * 0.25)
  • noOfLeds  = 20
  • width       = 20 px
  • height      = 229 px
This is in principle all we need to get started.
When it comes to coding we have different options in JavaFX on how to draw the led's
  • Use Regions with CSS
  • Use Circles optional with CSS
  • Use Canvas without CSS
Because we have 20 led's in our bar the two first approaches would need 20 nodes on the scene graph to visualize the led's. And because I like to save nodes on the scene graph I will go with the third option, using Canvas nodes without CSS.
The drawback when using the JavaFX Canvas is that you are responsible for drawing it, means everytime we want to set the current value we have to redraw the right Canvas node. Another drawback is that you can only hookup one mouse/touch listener to each Canvas. So if you want to have mouse events in different locations on your Canvas you need to implement the logic by yourself.
But because we only do visualization of values we don't need mouse/touch interaction anyway, so Canvas is fine.
So we will use two Canvas nodes, one for the background where we always draw all led's in an OFF state. This Canvas node only needs a redraw when the size of the control or the sections changes. The other Canvas node will be used to draw the currently active led's.

In principle this is all we need but I usually add another Pane to be able to give the control a specific background and border fill.
That means we will end up with 3 nodes for our custom control which is fine :)
The structure of a custom skin depends on your personal style of coding, if you take a look at my controls you will usually find the following methods

  • Constructor
  • init()                                           (primarily used for initial sizing)
  • initGraphics()                              (setup the scene graph once)
  • registerListeners()                       (register listeners to properties)
  • handleEvents(String eventType)   (handle events received by listeners)
  • redraw()                                      (reload graphic data and redraw)
  • resize()                                        (resize all components)
In addition you will often find different methods that will create or draw parts of the control.
In our case we will have an additional method for drawing the background named drawBackground() and another method to draw the currently active led's named setBar(double value).
I won't go through all the code because it's around 260 lines but we will take a look at the important parts.

Constructor

public CustomGaugeSkin(Gauge gauge) {
    super(gauge);
    if (gauge.isAutoScale()) gauge.calcAutoScale();
    range              = gauge.getRange();
    stepSize           = NO_OF_LEDS / range;
    barBackgroundColor = gauge.getBarBackgroundColor();
    barColor           = gauge.getBarColor();
    sectionsVisible    = gauge.getSectionsVisible();
    sections           = gauge.getSections();
    ledSize            = PREFERRED_WIDTH * 0.5;
    ledSpacer          = PREFERRED_WIDTH * 0.05;
    ledBorder          = PREFERRED_WIDTH * 0.25;

    init();
    initGraphics();
    registerListeners();
}

As you can see we only define some variables that we need before we call the init(), initGraphics() and registerListeners() methods.

The init() method is some kind of standard and not that interesting so let's take a look at the initGraphics() method where we set up the JavaFX scene graph.

initGraphics()

private void initGraphics() {
    backgroundCanvas = new Canvas(PREFERRED_WIDTH, PREFERRED_HEIGHT);
    backgroundCtx    = backgroundCanvas.getGraphicsContext2D();

    foregroundCanvas = new Canvas(PREFERRED_WIDTH, PREFERRED_HEIGHT);
    foregroundCtx    = foregroundCanvas.getGraphicsContext2D();

    ledInnerShadow   = new InnerShadow(BlurType.TWO_PASS_BOX, 
                                       Color.BLACK, 
                                       0.2 * PREFERRED_WIDTH, 
                                       0, 0, 0);
    ledDropShadow    = new DropShadow(BlurType.TWO_PASS_BOX, 
                                      getSkinnable().getBarColor(), 
                                      0.3 * PREFERRED_WIDTH, 
                                      0, 0, 0);

    pane = new Pane(backgroundCanvas, foregroundCanvas);
    pane.setBorder(new Border(new BorderStroke(getSkinnable().getBorderPaint(), 
                                               BorderStrokeStyle.SOLID,
                                               CornerRadii.EMPTY, 
                                               new BorderWidths(1))));
    pane.setBackground(new Background(new BackgroundFill(getSkinnable().getBackgroundPaint(), 
                                                         CornerRadii.EMPTY, 
                                                         Insets.EMPTY)));

    getChildren().setAll(pane);
}

In the initGraphics() method you can see that we create the two Canvas nodes, two effects that we need for the led's and the Pane I mentioned above. Then we add the Canvas nodes to the Pane and add the pane to the Control itself.
The next method we will take a look at is the redraw() method.

redraw()

private void redraw() {
    pane.setBackground(new Background(new BackgroundFill(getSkinnable().getBackgroundPaint(), 
                                                         CornerRadii.EMPTY, 
                                                         Insets.EMPTY)));
    sectionsVisible    = getSkinnable().getSectionsVisible();
    barBackgroundColor = getSkinnable().getBarBackgroundColor();
    barColor           = getSkinnable().getBarColor();
    drawBackground();
    setBar(getSkinnable().getCurrentValue());
}

Here we just take the current values for the colors and then draw the background and set the led bar to the current value. Everytime the width or height of the Control will change the resize() method will be called which looks like follows...

resize()

private void resize() {
    width  = getSkinnable().getWidth() - 
             getSkinnable().getInsets().getLeft() - 
             getSkinnable().getInsets().getRight();
    height = getSkinnable().getHeight() - 
             getSkinnable().getInsets().getTop() - 
             getSkinnable().getInsets().getBottom();

    if (ASPECT_RATIO * width > height) {
        width = 1 / (ASPECT_RATIO / height);
    } else if (1 / (ASPECT_RATIO / height) > width) {
        height = ASPECT_RATIO * width;
    }

    if (width > 0 && height > 0) {
        pane.setMaxSize(width, height);
        pane.relocate((getSkinnable().getWidth() - width) * 0.5, 
                      (getSkinnable().getHeight() - height) * 0.5);

        ledInnerShadow.setRadius(0.2 * width);
        ledDropShadow.setRadius(0.25 * width);

        ledSize   = width * 0.5;
        ledSpacer = width * 0.05;
        ledBorder = 0.25 * width;

        backgroundCanvas.setWidth(width);
        backgroundCanvas.setHeight(height);

        foregroundCanvas.setWidth(width);
        foregroundCanvas.setHeight(height);
    }
}

As you can see we first of all get the available width and height in consideration of the given insets. After that we calculate the control width and height dependent on the aspect ratio.
If the width and height are bigger than 0 we resize the three Nodes (2xCanvas and 1xPane), set the radii of the shadows and calculate parameters like ledSize, ledSpacer and ledBorder.
So there are two more methods left that we should take a look at, first the drawBackground() method.

drawBackground()

private void drawBackground() {
    backgroundCtx.clearRect(0, 0, width, height);
    backgroundCtx.setFill(barBackgroundColor);
    int listSize  = sections.size();
    Section currentSection;
    for (int i = 0 ; i < NO_OF_LEDS ; i++) {
        if (sectionsVisible) {
            double value = (i + 1) / stepSize;
            for (int j = 0 ; j < listSize ; j++) {
                currentSection = sections.get(j);
                if (currentSection.contains(value)) {
                    backgroundCtx.setFill(currentSection.getColor().darker().darker());
                    break;
                } else {
                    backgroundCtx.setFill(barBackgroundColor);
                }
            }
        }
        backgroundCtx.save();
        backgroundCtx.setEffect(ledInnerShadow);
        backgroundCtx.fillOval(ledBorder, 
                               height - ledSize - (i * (ledSpacer + ledSize)) - ledBorder, 
                               ledSize, 
                               ledSize);
        backgroundCtx.restore();
    }
}

Here we first clear the GraphicsContext because we are responsible for the drawing and clearing. I won't go through all steps because I think the code is somehow self explaining. After clearing the GraphicsContext we iterate over the number of led's and draw each led with either the barBackgroundColor or if we have sections enabled with the section color for the given led. To make the switched off led's look a bit better we apply an InnerShadow to each led.
This method only needs to be called when either the sections or section visibility change or when the control was resized.
The last method we will take a look at is the setBar(double value) method.

setValue(double value)

private void setBar(final double VALUE) {
    foregroundCtx.clearRect(0, 0, width, height);
    int            activeLeds = (int) Math.floor(VALUE * stepSize);
    int            listSize   = sections.size();
    Section        currentSection;
    RadialGradient gradient;
    foregroundCtx.setFill(barColor);
    for (int i = 0 ; i < activeLeds ; i++) {
        if (sectionsVisible) {
            double value = (i + 1) / stepSize;
            for (int j = 0 ; j < listSize ; j++) {
                currentSection = sections.get(j);
                if (currentSection.contains(value)) {
                    gradient = new RadialGradient(0, 
                                                  0, 
                                                  ledSize, 
                                                  height - ledSize - (i * (ledSpacer + ledSize)), 
                                                  ledBorder, 
                                                  false , 
                                                  CycleMethod.NO_CYCLE, 
                                                  new Stop(0, currentSection.getColor()), 
                                                  new Stop(0.55, currentSection.getColor()), 
                                                  new Stop(0.85, currentSection.getColor().darker()), 
                                                  new Stop(1, Color.rgb(0, 0, 0, 0.65)));
                    foregroundCtx.setFill(gradient);
                    ledDropShadow.setColor(currentSection.getColor());
                    break;
                } else {
                    gradient = new RadialGradient(0, 
                                                  0, 
                                                  ledSize, 
                                                  height - ledSize - (i * (ledSpacer + ledSize)), 
                                                  ledBorder, 
                                                  false , 
                                                  CycleMethod.NO_CYCLE, 
                                                  new Stop(0, barColor), 
                                                  new Stop(0.55, barColor), 
                                                  new Stop(0.85, barColor.darker()), 
                                                  new Stop(1, Color.rgb(0, 0, 0, 0.65)));
                    foregroundCtx.setFill(gradient);
                    ledDropShadow.setColor(barColor);
                }
            }
        } else {
            gradient = new RadialGradient(0, 
                                          0, 
                                          ledSize, 
                                          height - ledSize - (i * (ledSpacer + ledSize)), 
                                          ledBorder, 
                                          false , 
                                          CycleMethod.NO_CYCLE, 
                                          new Stop(0, barColor), 
                                          new Stop(0.55, barColor), 
                                          new Stop(0.85, barColor.darker()), 
                                          new Stop(1, Color.rgb(0, 0, 0, 0.65)));
            foregroundCtx.setFill(gradient);
            ledDropShadow.setColor(barColor);
        }
        foregroundCtx.save();
        foregroundCtx.setEffect(ledDropShadow);
        foregroundCtx.fillOval(ledBorder, 
                               height - ledSize - (i * (ledSpacer + ledSize)) - ledBorder, 
                               ledSize, 
                               ledSize);
        foregroundCtx.restore();
    }
}

In principle this method does the same as the drawBackground() method with the difference that it only draws as many led's as calculated from the given value. Means the given value will be converted to a number of led's that will be active. Then we iterate over this number and dependent on sections visible/available we color each led either with the barColor or with the appropriate section color.

Well that's nearly all that is needed to create our custom skin for the Medusa Gauge. 

You can find the complete code on github...

If we would like to use our new skin we need to simply set it like follows...
Gauge gauge = GaugeBuilder.create()
                          .backgroundPaint(Gauge.DARK_COLOR)
                          .barBackgroundColor(Color.DARKRED)
                          .barColor(Color.RED)
                          .minValue(0)
                          .maxValue(100)
                          .sectionsVisible(true)
                          .sections(new Section(0, 70, Color.LIME),
                                    new Section(70,85, Color.YELLOW),
                                    new Section(85, 100, Color.RED))
                          .build();

gauge.setSkin(new CustomGaugeSkin(gauge));
So we define a gauge with some parameters like min- and maxValue, colors for the bar and barBackground and some sections.
If you start the CustomGaugeSkinDemo from the Medusa demo project (github) you will see the following output...


In this demo you see 10 gauges with our new custom skin and the scene graph will only contain 40 nodes which is nice.

So that's it for today...keep coding... :)

No comments:

Post a Comment