7
\$\begingroup\$

I am new to front end development and thought to recreate the GUI of Fruity Balance just for practice. I would love to hear any advice on best practices. I am also wondering if any improvements could be made regarding scalability and extensibility (e.g. more knobs to be added in the future)?

clamp = (x, min, max) => { return Math.min(Math.max(x, min), max) }

class Knob {
    constructor(knobElement, min, max) {
        this.knobElement = knobElement;
        this.min = min;
        this.max = max;

        this.mouseDownY = 0;
        this.currentRotation = 0;
        this.mousePressed = false;

        this.knobElement.addEventListener("mousedown", (e) => {
            this.mousePressed = true;
            this.mouseDownY = e.clientY;
            this.currentRotation = parseInt(getComputedStyle(this.knobElement).getPropertyValue("--rotation"));

            // Change cursor
            document.body.style.cursor = "ns-resize";
        })

        document.addEventListener("mouseup", () => {
            this.mousePressed = false;

            // Revert cursor
            document.body.style.cursor = "";
        })

        document.addEventListener("mousemove", (e) => {
            if (!this.mousePressed) return;
            let newRotation = clamp(this.mouseDownY - e.clientY + this.currentRotation, this.min, this.max);
            this.knobElement.style.setProperty("--rotation", newRotation.toString() + "deg");
        })
    }
}

const knobElement1 = document.getElementsByClassName("knob")[0];
const knobElement2 = document.getElementsByClassName("knob")[1];

const knob1 = new Knob(knobElement1, -180, 180);
const knob2 = new Knob(knobElement2, 0, 360);
body {
    margin: 0;
    width: 100vw;
    height: 100vh;
    display: flex;
    justify-content: center;
    align-items: center;
}

.container {
    width: 400px;
    height: 260px;
    display: flex;
    justify-content: space-evenly;
    align-items: center;
    background: radial-gradient(circle at top, #767676 0%, #252525 100%);
    position: relative;
}

.meter {
    padding: 5px;
    width: 65px;
    height: 200px;
    border: 1.5px solid black;
    background: #767676;
    display: flex;
    justify-content: space-between;
    align-items: end;
}

.rec{
    width: 42%;
    height: 60%;
    background: #daff9a;
}

.knob {
    --rotation: 80deg;
    width: 90px;
    height: 90px;
    display: grid;
    position: relative;
    cursor: ns-resize;
}

.knob-dot-purple {
    width: 5%;
    height: 5%;
    border-radius: 100%;
    background: #ac71d4;
    position: absolute;
    left: 50%;
    top: -5%;
    transform: translate(-50%, -50%);
}

.knob-dot-blue {
    width: 5%;
    height: 5%;
    border-radius: 100%;
    background: #7fe2f1;
    position: absolute;
    left: 105%;
    top: 50%;
    transform: translate(-50%, -50%);
}

.knob-light-purple {
    /* --rotation: 30%; */
    width: 95%;
    height: 95%;
    border-radius: 100%;
    background: 
        /* Forwards */
        conic-gradient(from 0deg, #ac71d4 var(--rotation), transparent var(--rotation)), 
        /* Backwards */
        conic-gradient(from var(--rotation), #ac71d4 calc(-1*var(--rotation)), transparent calc(-1*var(--rotation)));
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
}

.knob-light-blue {
    /* --rotation: 80%; */
    width: 95%;
    height: 95%;
    border-radius: 100%;
    background: conic-gradient(from 180deg, #7fe2f1 var(--rotation), transparent var(--rotation));
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
}

.knob-layer-3 {
    width: 100%;
    height: 100%;
    border-radius: 100%;
    background: #626262;
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    box-shadow: inset 0em 0em 0.1em 0.1em rgb(82, 82, 82);
}

.knob-layer-2 {
    width: 85%;
    height: 85%;
    border-radius: 100%;
    background: linear-gradient(to bottom, #DDDDDD -10%, #444444 50%, #444444 50%, #4f4f4f 100%);
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    box-shadow: 0em 0.3em 0.1em 0.0001em rgba(0, 0, 0, 0.5), 0em 0.05em 0.1em 0.15em rgba(0, 0, 0, 0.1);
}

.knob-layer-1 {
    width: 65%;
    height: 65%;
    border-radius: 100%;
    background: linear-gradient(to bottom, #b9b9b9 0%, #828282 100%);
    position: absolute;
    left: 50%;
    top: 50%;
    transform: translate(-50%, -50%);
    box-shadow: 0em 0.05em 0.1em 0.15em rgb(59, 59, 59), inset 0em 0.2em 0.1em -0.1em #d5d5d5;
}

.text {
    font-family: Optima;
    color: #DDDDDD; 
    position: absolute;
    transform: translate(-50%, -50%);
    user-select: none;
}

.text.balance {
    font-size: 1.5em;
    left: 21%;
    top: 18%;
}

.text.volume {
    font-size: 1.5em;
    left: 79%;
    top: 18%;
}

.text.pan {
    font-size: 1em;
    left: 21%;
    top: 82%;
}

.text.db {
    font-size: 1em;
    left: 79%;
    top: 82%;
}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Fruity Balance</title>

    <link rel="stylesheet" href="css/style.css">

</head>
<body>
    
    
    <div class="container">

        <div class="knob">
            <div class="knob-dot-purple"></div>
            <div class="knob-layer-3"></div>
            <div class="knob-light-purple"></div>
            <div class="knob-layer-2"></div>
            <div class="knob-layer-1"></div>
        </div>

        <div class="meter">
            <div class="rec"></div>
            <div class="rec"></div>
        </div>

        <div class="knob">
            <div class="knob-dot-blue"></div>
            <div class="knob-layer-3"></div>
            <div class="knob-light-blue"></div>
            <div class="knob-layer-2"></div>
            <div class="knob-layer-1"></div>
        </div>

        <div class="text balance">Balance</div>
        <div class="text volume">Volume</div>
        <div class="text pan">Centered</div>
        <div class="text db">0.0dB 1.00</div>

    </div>

    <script src="js/script.js"></script>
</body>
</html>

\$\endgroup\$

1 Answer 1

3
\$\begingroup\$

Purely from a graphics perspective, this looks great. But there it pretty much ends. This sounds harsher than I intended it to be, but bear with me.

IMHO, there are a few fundamental issues that are all more or less connected to each other. Making this really flawed and unusable in the bigger picture.

1. Abstraction / Scalability / Extensibility

Abstraction is the key part of any IT project. Especially in terms of software development. The reason is that you want to do something only once, and always try to never repeat something. This is very important for scalability and extensibility.

In your HTML code, you have only 7 main elements:

  • 2 knobs
  • 1 scale
  • 4 text parts

They only work in this exact configuration. You can not just add a 3rd knob without breaking the rest. Nor can you really change a know. As an example, assume that I want 2 knobs for coloring, where I need "brightness" and "saturation". This would not work with your GUI, and I would need to redo it.

More simply put, in a real-life example:
Think about a "Pepper Mill" and a "Salt Mill". How do they differ from each other? Only by the content and maybe by the printed label. A designer does not design 2 different mills but only one mill and places a field for a label. The factory then fills them differently, but they do not have 2 different mills that they use.

The same rule applies to your code project. You think way too much of a single GUI instead of thinking about abstract (and repetitive) elements that you reuse.

What you actually only have from an abstract point of view are:

  • 2 control elements
  • 1 scale

Every control element contains a label, a control knob, and one unit of measurement. From a coding perspective, it would look something like this:

Elements = [Balance, Volume];
Elements.forEach(element => {
  // create container
  // create label and append to container
  // create knob and append to container
  // create unit of measurement and append to container
  // append container to the interface  
}

In terms of extensibility, what would you need to extend the tool with a 3rd or 4th knob? You simply would need to add it to the data array. Your CSS does not currently allow it, as you hardcoded the position. Therefore, think abstract and in terms of independent knobs. Position the label and the unit of measurement relative to the specific control element, not to the outer container.

1.2 CSS Abstraction

You have much repetitive code in your CSS, bloating it more than necessary. It is also because of your HTML structure, which could be simplified a lot.

Your HTML structure:

<div class="knob">
  <div class="knob-dot-purple"></div>
  <div class="knob-layer-3"></div>
  <div class="knob-light-purple"></div>
  <div class="knob-layer-2"></div>
  <div class="knob-layer-1"></div>
</div>

<div class="knob">
  <div class="knob-dot-blue"></div>
  <div class="knob-layer-3"></div>
  <div class="knob-light-blue"></div>
  <div class="knob-layer-2"></div>
  <div class="knob-layer-1"></div>
</div>

The HTML markup is the same. The slight difference is the class of the 1st and 3rd child. As such, you could simplify the CSS classes too:

<div class="knob purple">
  <div class="dot"></div>
  <div class="layer-3"></div>
  <div class="light"></div>
  <div class="layer-2"></div>
  <div class="layer-1"></div>
</div>

<div class="knob blue">
  <div class="dot"></div>
  <div class="layer-3"></div>
  <div class="light"></div>
  <div class="layer-2"></div>
  <div class="layer-1"></div>
</div>

That way, you can write a dedicated CSS module as nested CSS and cut all the repetitive CSS by using CSS variables.
ref: CSS Nesting Specifications, CSS Nesting Support

Your CSS:

.knob {
  --rotation: 80deg;
  width: 90px;
  height: 90px;
  display: grid;
  position: relative;
  cursor: ns-resize;
}

.knob-dot-purple {
  width: 5%;
  height: 5%;
  border-radius: 100%;
  background: #ac71d4;
  position: absolute;
  left: 50%;
  top: -5%;
  transform: translate(-50%, -50%);
}

.knob-dot-blue {
  width: 5%;
  height: 5%;
  border-radius: 100%;
  background: #7fe2f1;
  position: absolute;
  left: 105%;
  top: 50%;
  transform: translate(-50%, -50%);
}

This can be simplified by using nesting and variables to:

.knob {
  --rotation: 80deg;
  width: 90px;
  height: 90px;
  display: grid;
  position: relative;
  cursor: ns-resize;
  &.purple {
    --color: #ac71d4;
    --dot-left: 50%;
    --dot-top: -5%;
  }
  &.blue {
    --color: #7fe2f1;
    --dot-left: 105%;
    --dot-top: 50%;
  }
  .dot {
    width: 5%;
    height: 5%;
    border-radius: 100%;
    background: var(--color);
    position: absolute;
    left: var(--dot-left);
    top: var(--dot-right);
    transform: translate(-50%, -50%);
  }
}

That you can continue through your entire file. Making it easy to change the color as you simply need to change the variable once, and not require you to change it in every line where you used the color. If you want to make it even more abstract, then you detach the positioning from the color itself and create 4 classes.

2 UI/UX

The biggest issue you have is that you use px as the size for your container. That is an absolute unit and does not scale to the user's screen. Think about all the people who have a 4k screen or graphic designers with 8K screens, your control elements would use 1/19th of the width of an 8k screen and 1/10th of the 4k screen. The majority is white space. A good UI/UX adapts to the screen. Not necessarily fit the full screen, but make it larger at least.

There you have 2 different methods you can use here.

  • Flexbox + flex-wrap: wrap
  • CSS-Grid + auto-fill

All of those 2 methods allow you to fit as many of the control panels in one row as possible, and then wrap to the next row. This adapts to the width of the user's screen.

Another main issue is that you can control the knobs only by dragging the mouse up and down. Not able to control it with a keyboard or mouse wheel or even touch control (see section 4)

3 Accessibility

Accessibility-wise, this is a nightmare. An impaired person would only get announced the 4 text blocks in a row. They wouldn't even know that they are not connected to each other. You do not know that there are control units here; you can not focus the elements with a keyboard by tabbing through the elements in the correct order. You do not know the currently set values of the different controls.

None of your elements is focusable. I cannot tab between those elements back and forth and would never know where I am on your GUI.

3.1 Seamntics

You need to use semantic tags such as:

  • <label> for ` "Volume" and "Balance"
  • <output> where you display the exact values of the control
  • <progress> for the scale
  • <input> (visually hidden) to set the value of the control with a keyboard or for impaired users
3.2 Id vs class

Another issue that you have is, you are not using IDs. That is not just a bad practice, but also a pain for maintenance. First of all, I would not know what element the 1st element with the class "knob" is used for. If I were to change the order of the elements, then the code would break. Use an ID, as it would be absolutely clear what elements are meant, and it would remain correct if I change the order of the elements.

4 Object-Oriented vs Logic

This is a good use for OOP, but you never use your classes. So far, you have only a collection of functions that refer to Logic Programming. You are not using any attributes that you could use, such as value, min, or max. There are no getters and setters that can access those attributes, or where you can output those.

Simplified code example:

const dataArray = [
  {
    name: 'Volume',
    min: 0,
    max: 80,
    value: 40,
    unit: 'dB'
  }
];

class Knob {
  #min;
  #max;
  #value;
  #unit;
  
  constructor(data) {
    this.#min = data.min;
    this.#max = data.max;
    this.#value = data.value; 
    this.#unit = data.unit;
  }
  
  set value(value) {
    this.#value = Math.min(Math.max(value, this.#min), this.#max);
  }
  
  get value() {
    return `${parseInt(this.#value)}${this.#unit}`;
  }
  
  get valueAsPercentage() {
    return (this.#value / this.#max) * 100;
  }
  
  get rotation() {
    return `${parseInt(this.valueAsPercentage * 3.6)}deg`;
  }
}


// just for demo purpose
const test = new Knob(dataArray[0]);
testInput.addEventListener('input', function() {
  test.value = parseFloat(this.value);
  testOutputValue.value = test.value
  testOutputRotation.value = test.rotation;
  testProgress.value = test.valueAsPercentage;
})
<!-- just for demo purpose -->
<p>This is a generic test. Simply input a number between 0 and 80</p>

<label>
  Attribute "value": 
  <input type="number" min="0" max="80" value="40" id="testInput">
</label>
<br><br>
<label>
  Property "value":
  <output id="testOutputValue">40dB</output>
</label>
<br><br>
<label>
  Property "rotation":
  <output id="testOutputRotation">180deg</output>
</label>
<br><br>
<label>
  Property "valueAsPercentage":
  <progress id="testProgress" value="50" max="100"></progress>
</label>

In the example above, I am just demonstrating how a class should be used. The knobs should be created dynamically based on a data array. It does not matter if this is a constant or comes from a JSON or SQL. The attributes should be private, while the only attribute that should be allowed to be changed is the value. Next, you write getters to get the properties you want.

\$\endgroup\$

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.