Relay, WireComponent, recalculate Calculation, Wire disconnectable

This commit is contained in:
Patrick Haßel 2025-02-03 13:08:57 +01:00
parent 21084b2de2
commit c66ae0854d
25 changed files with 527 additions and 90 deletions

View File

@ -23,7 +23,26 @@ export class Circuit {
}
calculate() {
for (let i = 0; i < 50; i++) {
Calculation.calculate(this);
const changedParts = [];
for (const part of this.parts) {
if (part.loop()) {
changedParts.push(part);
}
}
if (changedParts.length === 0) {
return;
}
console.log("Recalculating due to parts changed:", changedParts.map(p => p.name).join(", "));
}
console.error("Too many recalculations!");
this.resetCalculations();
}
private resetCalculations() {
this.parts.forEach(part => part.resetCalculations());
this.wires.forEach(wire => wire.resetCalculations());
}
}

View File

@ -0,0 +1,28 @@
import {Circuit} from "./Circuit";
import {Battery} from '../parts/battery/Battery';
import {Light} from '../parts/light/Light';
import {Wire} from '../wire/Wire';
import {RESISTANCE_MIN} from './Calculation';
import {Relay} from '../parts/relay/Relay';
const batteryLight = new Battery(1, 1, "Licht Batterie");
const light = new Light(2, 1, "Licht");
const relay = new Relay(1, 2, "Relais");
const batteryRelay = new Battery(1, 3, "Relais Batterie");
export const DEMO_003 = new Circuit(
"DEMO_003",
"3. Relais",
[batteryRelay, relay, batteryLight, light],
[
new Wire(batteryLight.plus, light.a, RESISTANCE_MIN, ""),
new Wire(batteryLight.minus, relay.common, RESISTANCE_MIN, ""),
new Wire(light.b, relay.active, RESISTANCE_MIN, ""),
new Wire(batteryRelay.minus, relay.coilA, RESISTANCE_MIN, ""),
new Wire(batteryRelay.plus, relay.coilB, RESISTANCE_MIN, ""),
],
);

View File

@ -2,6 +2,7 @@ import {Point} from '../Point';
import {Part, RASTER} from '../parts/Part';
import {Battery} from '../parts/battery/Battery';
import {Light} from '../parts/light/Light';
import {Relay} from '../parts/relay/Relay';
import {Circuit} from './Circuit';
export class Parts {
@ -49,6 +50,10 @@ export class Parts {
return this.add(new Light(rasterX, rasterY, this.generateName("Licht")));
}
newRelay(rasterX: number, rasterY: number): Relay {
return this.add(new Relay(rasterX, rasterY, this.generateName("Relais")));
}
private generateName(baseName: string) {
let counter = 1;
let name: string;

View File

@ -107,8 +107,7 @@ export class Wires {
disconnect(wire: Wire) {
this.circuit.wires.splice(this.circuit.wires.indexOf(wire), 1);
wire.start.wires.splice(wire.start.wires.indexOf(wire), 1);
wire.end.wires.splice(wire.end.wires.indexOf(wire), 1);
wire.disconnect();
console.log("Wire disconnected: ", wire);
this.circuit.calculate();
}

View File

@ -1,20 +1,20 @@
.circuit {
width: 100%;
height: 100%;
-webkit-user-select: none;
-ms-user-select: none;
user-select: none;
.wire {
stroke-width: 9px;
stroke-linecap: round;
pointer-events: none;
}
.wireBack {
stroke-linecap: round;
stroke-width: 11px;
stroke: black;
pointer-events: none;
}
.wire {
stroke-width: 9px;
stroke-dasharray: 0 20;
stroke-linecap: round;
pointer-events: none;
}
.wireOpen {
@ -29,5 +29,3 @@
.wireDuplicate {
stroke: red;
}
}

View File

@ -2,27 +2,7 @@
<g inner-part *ngFor="let part of circuit.parts" [part]="part" [parts]="parts" [wires]="wires"></g>
<ng-container *ngFor="let wire of circuit.wires">
<line
class="wire wireBack"
[attr.x1]="wire.start.pixelX + 'px'"
[attr.y1]="wire.start.pixelY + 'px'"
[attr.x2]="wire.end.pixelX + 'px'"
[attr.y2]="wire.end.pixelY + 'px'"
></line>
<line
class="wire"
[attr.stroke]="voltageColor(wire)"
[attr.x1]="wire.start.pixelX + 'px'"
[attr.y1]="wire.start.pixelY + 'px'"
[attr.x2]="wire.end.pixelX + 'px'"
[attr.y2]="wire.end.pixelY + 'px'"
stroke-dasharray="1, 15"
>
<animate *ngIf="wire.current < 0" attributeName="stroke-dashoffset" from="0" to="16" [attr.dur]="(1 / wire.absCurrent) / 200" repeatCount="indefinite"/>
<animate *ngIf="wire.current > 0" attributeName="stroke-dashoffset" from="16" to="0" [attr.dur]="(1 / wire.absCurrent) / 200" repeatCount="indefinite"/>
</line>
</ng-container>
<g inner-wire *ngFor="let wire of circuit.wires" [globalWire]="wire"></g>
<ng-container *ngIf="wires.dragStart && wires.dragCursor && wires.dragStartJunction">
<ng-container *ngIf="wires.dragEnd && wires.dragEndJunction">

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -4,10 +4,9 @@ import {MessageService} from '../message/message.service';
import {Wires} from './Wires';
import {Parts} from './Parts';
import {PartComponent} from '../parts/part.component';
import {Wire} from '../wire/Wire';
import {fadeColor} from '../colorHelpers';
import {Circuit} from './Circuit';
import {WireComponent} from '../part-wire/wire.component';
@Component({
selector: 'app-circuit',
@ -15,6 +14,7 @@ import {Circuit} from './Circuit';
NgForOf,
NgIf,
PartComponent,
WireComponent,
],
templateUrl: './circuit.component.svg',
styleUrl: './circuit.component.less'
@ -55,15 +55,4 @@ export class CircuitComponent {
this.parts.mouseUp($event);
}
voltageColor(wire: Wire) {
if (wire.start.voltage === null || wire.start.minCircuitVoltage === null || wire.start.maxCircuitVoltage === null) {
return 'gray';
}
const ratio = (wire.start.voltage - wire.start.minCircuitVoltage) / (wire.start.maxCircuitVoltage - wire.start.minCircuitVoltage);
if (ratio < 0.5) {
return fadeColor(ratio * 2, '#008cff', 'magenta');
}
return fadeColor((ratio - 0.5) * 2, 'magenta', 'red');
}
}

View File

@ -3,7 +3,7 @@ import {CircuitComponent} from './circuit/circuit.component';
import {MessagesComponent} from './message/messages/messages.component';
import {MenubarComponent} from './menubar/menubar.component';
import {Circuit} from './circuit/Circuit';
import {DEMO_002} from './circuit/DEMO_002';
import {DEMO_003} from './circuit/DEMO_003';
@Component({
selector: 'app-editor',
@ -20,7 +20,7 @@ export class EditorComponent implements OnInit {
private _circuit: Circuit = Circuit.new();
ngOnInit(): void {
this.circuit = DEMO_002;
this.circuit = DEMO_003;
}
set circuit(circuit: Circuit) {

View File

@ -2,6 +2,7 @@ import {Wire} from "../wire/Wire";
import {Part} from '../parts/Part';
import {Rect} from '../../Rect';
import {selectTowardsRoot} from '../../selectTowardsRoot';
import {fadeColor} from '../colorHelpers';
export const JUNCTION_RADIUS_PERCENT = 15;
@ -9,9 +10,9 @@ export class Junction {
readonly uuid: string = self.crypto.randomUUID();
readonly percentX: number;
readonly centerPercentX: number;
readonly percentY: number;
readonly centerPercentY: number;
readonly wires: Wire[] = [];
@ -25,11 +26,11 @@ export class Junction {
constructor(
public readonly part: Part,
percentX: number,
percentY: number,
public readonly percentX: number,
public readonly percentY: number,
readonly name: string) {
this.percentX = percentX - JUNCTION_RADIUS_PERCENT;
this.percentY = percentY - JUNCTION_RADIUS_PERCENT;
this.centerPercentX = percentX - JUNCTION_RADIUS_PERCENT;
this.centerPercentY = percentY - JUNCTION_RADIUS_PERCENT;
}
get fullName(): string {
@ -77,7 +78,7 @@ export class Junction {
private findRect(): Rect {
const child = document.getElementById(this.uuid);
if (!child) {
throw Error(`No HTMLElement found for Junction: uuid=${this.uuid}, name=${this.name}`);
throw Error(`No HTMLElement found for Junction: name=${this.fullName}, uuid=${this.uuid}`);
}
const parent = selectTowardsRoot(child, "svg.circuit");
@ -95,4 +96,19 @@ export class Junction {
};
}
voltageColor(): string {
if (this.voltage === null || this.minCircuitVoltage === null || this.maxCircuitVoltage === null) {
return 'lightgray';
}
const ratio = (this.voltage - this.minCircuitVoltage) / (this.maxCircuitVoltage - this.minCircuitVoltage);
if (ratio < 0.5) {
return fadeColor(ratio * 2, '#008cff', 'magenta');
}
return fadeColor((ratio - 0.5) * 2, 'magenta', 'red');
}
resetCalculations() {
this.voltage = null;
}
}

View File

@ -1,7 +1,7 @@
<svg
class="junction"
[attr.x]="junction.percentX + '%'"
[attr.y]="junction.percentY + '%'"
[attr.x]="junction.centerPercentX + '%'"
[attr.y]="junction.centerPercentY + '%'"
[attr.height]="junction.percentH + '%'"
[attr.width]="junction.percentW + '%'"
(mousedown)="wires.mouseDown(junction, $event)"

Before

Width:  |  Height:  |  Size: 527 B

After

Width:  |  Height:  |  Size: 539 B

View File

@ -3,6 +3,7 @@ import {NgForOf} from '@angular/common';
import {Circuit} from '../circuit/Circuit';
import {DEMO_001} from '../circuit/DEMO_001';
import {DEMO_002} from '../circuit/DEMO_002';
import {DEMO_003} from '../circuit/DEMO_003';
@Component({
selector: 'app-menubar',
@ -20,6 +21,6 @@ export class MenubarComponent {
@Output()
load: EventEmitter<Circuit> = new EventEmitter();
circuits: Circuit[] = [DEMO_001, DEMO_002];
circuits: Circuit[] = [DEMO_001, DEMO_002, DEMO_003];
}

View File

@ -0,0 +1,5 @@
@import "../parts/part.component.less";
.partWireBack {
stroke-width: 5px;
}

View File

@ -0,0 +1,11 @@
<svg width="100%" height="100%">
<line class="wireBack {{extraClassesBack}}" [attr.x1]="x1" [attr.y1]="y1" [attr.x2]="x2" [attr.y2]="y2">
<!-- nothing -->
</line>
<line class="wire" [attr.stroke]="color" [attr.x1]="x1" [attr.y1]="y1" [attr.x2]="x2" [attr.y2]="y2">
<animate *ngIf="current" attributeName="stroke-dashoffset" [attr.from]="animateFrom" [attr.to]="animateTo" [attr.dur]="animateDuration" repeatCount="indefinite"/>
</line>
</svg>

After

Width:  |  Height:  |  Size: 462 B

View File

@ -0,0 +1,172 @@
import {Component, Input} from '@angular/core';
import {ANIMATION_ELECTRON_STEPS, Wire} from '../wire/Wire';
import {Junction} from '../junction/Junction';
import {fadeColor} from '../colorHelpers';
import {NgIf} from '@angular/common';
@Component({
selector: 'g [inner-wire]',
imports: [
NgIf
],
templateUrl: './wire.component.svg',
styleUrl: './wire.component.less'
})
export class WireComponent {
protected wire: Wire | null = null;
protected isWirePart: boolean = false;
protected fromJ: Junction | null = null;
protected toJ: Junction | null = null;
private _manualCurrent_: number = 0;
@Input()
extraClassesBack: string = "";
@Input()
set partWire(wire: Wire) {
this.wire = wire;
this.isWirePart = true;
}
@Input()
set globalWire(wire: Wire) {
this.wire = wire;
this.isWirePart = false;
}
@Input()
set fromJunction(junction: Junction) {
this.fromJ = junction;
}
@Input()
set toJunction(junction: Junction) {
this.toJ = junction;
}
@Input()
fromX = "";
@Input()
fromY = "";
@Input()
toX = "";
@Input()
toY = "";
@Input()
set current(current: number) {
this._manualCurrent_ = current;
}
get current(): number {
if (this.wire) {
return this.wire.current;
}
return this._manualCurrent_;
}
protected get voltage(): number | null {
if (this.wire) {
if (this.wire.start.voltage === null || this.wire.end.voltage === null) {
return null;
}
return (this.wire.start.voltage + this.wire.end.voltage) / 2;
} else if (this.fromJ) {
return this.fromJ.voltage;
} else if (this.toJ) {
return this.toJ.voltage;
}
return null;
}
protected get x1(): string {
if (this.wire) {
if (this.isWirePart) {
return this.wire.start.percentX + '%';
} else {
return this.wire.start.pixelX + 'px';
}
} else if (this.fromJ) {
return this.fromJ.percentX + '%';
}
return this.fromX;
}
protected get y1(): string {
if (this.wire) {
if (this.isWirePart) {
return this.wire.start.percentY + '%';
} else {
return this.wire.start.pixelY + 'px';
}
} else if (this.fromJ) {
return this.fromJ.percentY + '%';
}
return this.fromY;
}
protected get x2(): string {
if (this.wire) {
if (this.isWirePart) {
return this.wire.end.percentX + '%';
} else {
return this.wire.end.pixelX + 'px';
}
} else if (this.toJ) {
return this.toJ.percentX + '%';
}
return this.toX;
}
protected get y2(): string {
if (this.wire) {
if (this.isWirePart) {
return this.wire.end.percentY + '%';
} else {
return this.wire.end.pixelY + 'px';
}
} else if (this.toJ) {
return this.toJ.percentY + '%';
}
return this.toY;
}
protected get animateFrom(): number {
return this.current < 0 ? 0 : ANIMATION_ELECTRON_STEPS;
}
protected get animateTo(): number {
return this.current < 0 ? ANIMATION_ELECTRON_STEPS : 0;
}
protected get animateDuration(): number {
return Math.max(0.05, (1 / Math.abs(this.current)) / 200);
}
protected get color(): string {
const voltage = this.voltage;
const junction: Junction | null = this.wire ? this.wire.start : this.fromJ ? this.fromJ : this.toJ;
if (!junction) {
throw new Error("Cannot calculate color if no voltage (or junction) given.");
}
const vMin = junction.minCircuitVoltage;
const vMax = junction.maxCircuitVoltage;
if (voltage === null || vMin === null || vMax === null) {
return "lightgray";
}
const ratio = (voltage - vMin) / (vMax - vMin);
if (ratio < 0.5) {
return fadeColor(ratio * 2, '#008cff', 'magenta');
}
return fadeColor((ratio - 0.5) * 2, 'magenta', 'red');
}
}

View File

@ -29,8 +29,6 @@ export abstract class Part {
this._h = rasterH * 3 * RASTER;
}
abstract get junctions(): Junction[];
get x(): number {
return this._x;
}
@ -63,4 +61,19 @@ export abstract class Part {
this.junctions.forEach(junction => junction.updatePosition());
}
loop(): boolean {
return false;
};
resetCalculations() {
this.junctions.forEach(junction => junction.resetCalculations());
this.resetCalculations2();
}
resetCalculations2(): void {
//
}
abstract get junctions(): Junction[];
}

View File

@ -1,7 +1,7 @@
<svg width="100%" height="100%">
<text dominant-baseline="hanging" text-anchor="middle" x="50%" y="3%">{{ battery.voltageStr }}</text>
<line class="partWire" id="wireMinus" x1="15%" x2="40%" y1="50%" y2="50%"></line>
<line class="partWire" id="wirePlus" x1="55%" x2="80%" y1="50%" y2="50%"></line>
<g inner-wire fromX="40%" fromY="50%" [toJunction]="battery.minus" [current]="battery.current" extraClassesBack="partWireBack"></g>
<g inner-wire [fromJunction]="battery.plus" toX="60%" toY="50%" [current]="battery.current" extraClassesBack="partWireBack"></g>
<rect height="30%" id="symbolMinus" width="10%" x="35%" y="35%"></rect>
<rect height="60%" id="symbolPlus" width="5%" x="55%" y="20%"></rect>
<text text-anchor="middle" x="50%" y="95%">{{ battery.currentStr }}</text>

Before

Width:  |  Height:  |  Size: 534 B

After

Width:  |  Height:  |  Size: 632 B

View File

@ -1,9 +1,12 @@
import {Component, Input} from '@angular/core';
import {Battery} from './Battery';
import {WireComponent} from '../../part-wire/wire.component';
@Component({
selector: 'g[inner-part-battery]',
imports: [],
imports: [
WireComponent
],
templateUrl: './battery.component.svg',
styleUrl: './battery.component.less',
})

View File

@ -1,3 +1,5 @@
@import "../circuit/circuit.component.less";
.part {
border-radius: 0.51em;
border: 1px solid transparent;
@ -18,7 +20,6 @@
opacity: 0.7;
}
.partWire {
.partWireBack {
stroke-width: 5px;
stroke: black;
}

View File

@ -15,6 +15,8 @@
<g inner-part-light *ngIf="isLight(part)" [light]="asLight(part)"></g>
<g inner-part-relay *ngIf="isRelay(part)" [relay]="asRelay(part)"></g>
<g inner-junction *ngFor="let junction of part.junctions" [junction]="junction" [wires]="wires"></g>
</svg>

Before

Width:  |  Height:  |  Size: 621 B

After

Width:  |  Height:  |  Size: 695 B

View File

@ -8,6 +8,8 @@ import {Parts} from '../circuit/Parts';
import {Wires} from '../circuit/Wires';
import {LightComponent} from './light/light.component';
import {JunctionComponent} from '../junction/junction.component';
import {Relay} from './relay/Relay';
import {RelayComponent} from './relay/relay.component';
@Component({
selector: 'g[inner-part]',
@ -16,7 +18,8 @@ import {JunctionComponent} from '../junction/junction.component';
NgIf,
NgForOf,
LightComponent,
JunctionComponent
JunctionComponent,
RelayComponent
],
templateUrl: './part.component.svg',
styleUrl: './part.component.less',
@ -48,4 +51,12 @@ export class PartComponent {
return part instanceof Light;
}
asRelay(part: Part): Relay {
return part as Relay;
}
isRelay(part: Part): boolean {
return part instanceof Relay;
}
}

View File

@ -0,0 +1,91 @@
import {Part} from "../Part";
import {Junction} from '../../junction/Junction';
import {PartType} from '../PartType';
import {siPrefix} from '../../siPrefix';
import {Wire} from '../../wire/Wire';
import {RESISTANCE_MIN} from '../../circuit/Calculation';
export class Relay extends Part {
readonly common = new Junction(this, 15, 50, "COM");
readonly active = new Junction(this, 85, 50, "NO");
readonly inactive = new Junction(this, 85, 15, "NC");
readonly coilA = new Junction(this, 15, 85, "A");
readonly coilB = new Junction(this, 85, 85, "B");
readonly coil: Wire;
contact: Wire = new Wire(this.common, this.inactive, RESISTANCE_MIN, "Schaltkontakt");
constructor(
rasterX: number,
rasterY: number,
name: string,
public voltageMin: number = 1.5,
public voltageMax: number = 3,
public resistance: number = 150,
) {
super(PartType.Light, name, rasterX, rasterY);
this.coil = new Wire(this.coilA, this.coilB, resistance, "Spule");
}
override get junctions(): Junction[] {
return [this.coilA, this.coilB, this.common, this.active, this.inactive];
}
get voltage(): number {
if (this.coilA.voltage == null || this.coilB.voltage == null) {
return 0;
}
return this.coilB.voltage - this.coilA.voltage;
}
get isCoilActive(): boolean {
return this.voltage >= this.voltageMin && !this.defect;
}
get defect(): boolean {
return this.voltage > this.voltageMax;
}
get voltageStr(): string {
return siPrefix(this.voltage, 'V', 2);
}
get current(): number {
if (this.coilA.voltage === null || this.coilB.voltage === null) {
return 0;
}
return (this.coilB.voltage - this.coilA.voltage) / this.resistance;
}
get currentStr(): string {
const current = Math.abs(this.current) || 0;
return siPrefix(current, 'A', 2);
}
override loop(): boolean {
const shouldBeActive = this.isCoilActive;
const isActive = this.contact.end === this.active;
if (isActive != shouldBeActive) {
this.contact.disconnect();
if (shouldBeActive) {
this.contact = new Wire(this.common, this.active, RESISTANCE_MIN, "Schaltkontakt Aktiv");
} else {
this.contact = new Wire(this.common, this.inactive, RESISTANCE_MIN, "Schaltkontakt Inaktiv");
}
return true;
}
return false;
}
override resetCalculations2() {
this.contact.resetCalculations();
this.coil.resetCalculations();
}
}

View File

@ -0,0 +1,21 @@
@import "../part.component.less";
.defect {
filter: drop-shadow(0 0 30px black);
}
.mechanic {
stroke-width: 3px;
stroke: dimgray;
stroke-dasharray: 5 5;
}
.coil {
stroke: black;
stroke-width: 1px;
fill: darkgray;
}
.coilActive {
fill: dodgerblue;
}

View File

@ -0,0 +1,30 @@
<svg width="100%" height="100%">
<text dominant-baseline="hanging" x="25%" y="3%">{{ relay.voltageStr }}</text>
<text dominant-baseline="hanging" x="25%" y="15%">{{ relay.currentStr }}</text>
<ng-container *ngIf="!relay.isCoilActive">
<line class="mechanic"
[attr.x1]="50 + '%'"
[attr.y1]="relay.common.percentY + 8 - 20 + '%'"
[attr.x2]="50 + '%'"
[attr.y2]="65 + '%'"
></line>
<g inner-wire [partWire]="relay.contact"></g>
</ng-container>
<ng-container *ngIf="relay.isCoilActive">
<line class="mechanic"
[attr.x1]="50 + '%'"
[attr.y1]="relay.common.percentY + '%'"
[attr.x2]="50 + '%'"
[attr.y2]="65 + '%'"
></line>
<g inner-wire [partWire]="relay.contact"></g>
</ng-container>
<g inner-wire [partWire]="relay.coil" extraClassesBack="partWireBack"></g>
<rect class="coil" [class.coilActive]="relay.isCoilActive" [class.defect]="relay.defect" x="40%" y="65%" width="20%" height="30%"></rect>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@ -0,0 +1,23 @@
import {Component, Input} from '@angular/core';
import {Relay} from './Relay';
import {JUNCTION_RADIUS_PERCENT} from '../../junction/Junction';
import {NgIf} from '@angular/common';
import {WireComponent} from '../../part-wire/wire.component';
@Component({
selector: 'g[inner-part-relay]',
imports: [
NgIf,
WireComponent
],
templateUrl: './relay.component.svg',
styleUrl: './relay.component.less',
})
export class RelayComponent {
@Input()
relay!: Relay;
protected readonly JUNCTION_RADIUS_PERCENT = JUNCTION_RADIUS_PERCENT;
}

View File

@ -1,5 +1,8 @@
import {Junction} from "../junction/Junction";
import {RESISTANCE_MIN} from '../circuit/Calculation';
import {fadeColor} from '../colorHelpers';
export const ANIMATION_ELECTRON_STEPS = 20;
export class Wire {
@ -11,15 +14,16 @@ export class Wire {
public resistance: number,
public name: string | null = null,
) {
this.start.wires.push(this);
this.end.wires.push(this);
if (this.resistance === 0) {
this.resistance = RESISTANCE_MIN;
}
this.start.wires.push(this);
this.end.wires.push(this);
}
get absCurrent(): number {
return Math.abs(this.current);
disconnect() {
this.start.wires.splice(this.start.wires.indexOf(this), 1);
this.end.wires.splice(this.end.wires.indexOf(this), 1);
}
toString() {
@ -39,4 +43,19 @@ export class Wire {
throw new Error(`Wire is not connected to given Junction: wire=${this}, junction=${junction}`);
}
resetCalculations() {
this.current = 0;
}
voltageColor(): string {
if (this.start.voltage === null || this.end.voltage === null || this.start.minCircuitVoltage === null || this.start.maxCircuitVoltage === null) {
return 'lightgray';
}
const ratio = ((this.end.voltage + this.start.voltage) / 2 - this.start.minCircuitVoltage) / (this.start.maxCircuitVoltage - this.start.minCircuitVoltage);
if (ratio < 0.5) {
return fadeColor(ratio * 2, '#008cff', 'magenta');
}
return fadeColor((ratio - 0.5) * 2, 'magenta', 'red');
}
}