mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 09:20:15 +00:00
Update attack labels (#3784)
## Description: The motivation behind this PR is to standardize colors & icons for incoming and outgoing attacks. Outgoing attacks are always aquarious and incoming are red. This also makes it much easier to see which attacks are incoming vs outgoing at a glance, as previously the color changed depending on attack effictiveness. Instead, show a small bar on the left side that displays attack effectiveness. <img width="498" height="456" alt="Screenshot 2026-04-27 at 12 58 53 PM" src="https://github.com/user-attachments/assets/ea6928b3-5dfa-47fa-84d2-63e1e81ef6a4" /> Updates the in-game attack labels to match AttacksDisplay: a single soldier icon recolored via CSS filters, aquarius for outgoing and red-400 for incoming. Color is now purely directional — the previous attacker-vs-defender comparison (and the troopAttackColor / troopDefenceColor helpers that drove it) is gone, along with the defenderTroops plumbing. Also adds zoom-aware sizing via a new computeLabelScale(zoom) (full screen size when zoomed in, linear shrink with a floor so labels never disappear), bumps font/padding/snap-jump threshold for readability, and moves immutable per-label DOM writes (icon src/filter, color) into element creation so the per-tick path only updates the troop count. Also fixes a bug where the labels kept swapping when 2 clusters where similar size ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: evan
This commit is contained in:
@@ -7,21 +7,60 @@ import { AlternateViewEvent } from "../../InputHandler";
|
||||
import { renderTroops } from "../../Utils";
|
||||
import { TransformHandler } from "../TransformHandler";
|
||||
import { Layer } from "./Layer";
|
||||
const shieldIcon = assetUrl("images/ShieldIconWhite.svg");
|
||||
const swordIcon = assetUrl("images/SwordIconWhite.svg");
|
||||
const soldierIcon = assetUrl("images/SoldierIcon.svg");
|
||||
|
||||
export function troopAttackColor(
|
||||
attackerTroops: number,
|
||||
defenderTroops: number,
|
||||
): string {
|
||||
return attackerTroops > defenderTroops ? "#66ff66" : "#ffbe3c";
|
||||
// Match AttacksDisplay: aquarius for outgoing, red-400 for incoming.
|
||||
const OUTGOING_COLOR = "var(--color-aquarius)";
|
||||
const INCOMING_COLOR = "var(--color-red-400)";
|
||||
|
||||
// At/above this zoom, the label stays at its full screen size. Below it the
|
||||
// label shrinks linearly with zoom-out, floored so it never disappears.
|
||||
const LABEL_FULL_SIZE_ZOOM = 1.5;
|
||||
const LABEL_MIN_SCREEN_SCALE = 0.5;
|
||||
const OUTGOING_ICON_FILTER =
|
||||
"brightness(0) saturate(100%) invert(62%) sepia(80%) saturate(500%) hue-rotate(175deg) brightness(100%)";
|
||||
const INCOMING_ICON_FILTER =
|
||||
"brightness(0) saturate(100%) invert(27%) sepia(91%) saturate(4551%) hue-rotate(348deg) brightness(89%) contrast(97%)";
|
||||
|
||||
// Vertical strength bar to the left of the icon: grows in height as the
|
||||
// attacker outnumbers the opposition. Maxes out at BAR_MAX_HEIGHT_PX when the
|
||||
// attacker has BAR_FULL_HEIGHT_RATIO× the opposing troops.
|
||||
const BAR_FULL_HEIGHT_RATIO = 2;
|
||||
const BAR_MAX_HEIGHT_PX = 13;
|
||||
|
||||
// Element scale factor that, combined with the container's `scale(zoom)`,
|
||||
// yields the desired on-screen label size: constant screen size when zoomed
|
||||
// in past LABEL_FULL_SIZE_ZOOM, then shrinking linearly as zoom drops, with a
|
||||
// floor at LABEL_MIN_SCREEN_SCALE so the label never disappears.
|
||||
export function computeLabelScale(zoom: number): number {
|
||||
const netScale = Math.max(
|
||||
LABEL_MIN_SCREEN_SCALE,
|
||||
Math.min(1, zoom / LABEL_FULL_SIZE_ZOOM),
|
||||
);
|
||||
return netScale / zoom;
|
||||
}
|
||||
|
||||
export function troopDefenceColor(
|
||||
// Fraction (0–1) of BAR_MAX_HEIGHT_PX the strength bar should occupy. 0 means
|
||||
// the attacker is harmless; 1 means they have BAR_FULL_HEIGHT_RATIO× or more
|
||||
// of the opposing troops.
|
||||
export function computeBarStrength(
|
||||
attackerTroops: number,
|
||||
myTroops: number,
|
||||
): string {
|
||||
return attackerTroops > myTroops ? "#ff4444" : "#ff9944";
|
||||
opposingTroops: number,
|
||||
): number {
|
||||
if (opposingTroops <= 0) return 1;
|
||||
return Math.min(1, attackerTroops / opposingTroops / BAR_FULL_HEIGHT_RATIO);
|
||||
}
|
||||
|
||||
// Worker returns clusters sorted by size; two near-equal-size fronts can flip
|
||||
// ordering tick-to-tick. If swapping brings each new position closer to where
|
||||
// its label already is, swap `next` in place. (clusteredPositions caps at 2.)
|
||||
export function alignClusterOrder(next: Cell[], prev: (Cell | null)[]): void {
|
||||
const [a, b] = prev;
|
||||
if (next.length !== 2 || !a || !b) return;
|
||||
const dist = (p: Cell, q: Cell) => Math.abs(p.x - q.x) + Math.abs(p.y - q.y);
|
||||
const direct = dist(next[0], a) + dist(next[1], b);
|
||||
const swapped = dist(next[1], a) + dist(next[0], b);
|
||||
if (swapped < direct) [next[0], next[1]] = [next[1], next[0]];
|
||||
}
|
||||
|
||||
// An attack can have multiple disconnected front-line segments, so elements
|
||||
@@ -31,7 +70,7 @@ interface AttackLabel {
|
||||
positions: (Cell | null)[];
|
||||
isIncoming: boolean;
|
||||
attackerTroops: number;
|
||||
defenderTroops: number;
|
||||
barStrength: number;
|
||||
}
|
||||
|
||||
export class AttackingTroopsOverlay implements Layer {
|
||||
@@ -42,6 +81,9 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
private inFlightRequest = false;
|
||||
private isVisible = true;
|
||||
private onAlternateView: (e: AlternateViewEvent) => void;
|
||||
// Last transform string written per element; lets renderLayer skip identical
|
||||
// re-assignments every frame (~60fps × N labels).
|
||||
private lastTransform = new WeakMap<HTMLDivElement, string>();
|
||||
|
||||
constructor(
|
||||
private readonly game: GameView,
|
||||
@@ -84,6 +126,10 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
return 200;
|
||||
}
|
||||
|
||||
private labelScale(): number {
|
||||
return computeLabelScale(this.transformHandler.scale);
|
||||
}
|
||||
|
||||
tick() {
|
||||
if (!this.userSettings.attackingTroopsOverlay() || !this.isVisible) {
|
||||
if (this.labels.size > 0) this.clearAllLabels();
|
||||
@@ -98,7 +144,7 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
|
||||
const activeIDs = new Set<string>();
|
||||
|
||||
// Outgoing attacks — green if winning, amber if losing.
|
||||
// Outgoing: cyan bar widens as our attack outnumbers the defender.
|
||||
for (const attack of myPlayer.outgoingAttacks()) {
|
||||
activeIDs.add(attack.id);
|
||||
if (!attack.targetID) {
|
||||
@@ -110,10 +156,11 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
this.removeLabel(attack.id);
|
||||
continue;
|
||||
}
|
||||
this.ensureLabel(attack.id, attack.troops, defender.troops(), false);
|
||||
const barStrength = computeBarStrength(attack.troops, defender.troops());
|
||||
this.ensureLabel(attack.id, attack.troops, false, barStrength);
|
||||
}
|
||||
|
||||
// Incoming attacks — red if the attacker outnumbers the player, orange otherwise.
|
||||
// Incoming: red bar widens as the attacker outnumbers the player.
|
||||
for (const attack of myPlayer.incomingAttacks()) {
|
||||
activeIDs.add(attack.id);
|
||||
const attacker = this.game.playerBySmallID(attack.attackerID);
|
||||
@@ -121,7 +168,8 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
this.removeLabel(attack.id);
|
||||
continue;
|
||||
}
|
||||
this.ensureLabel(attack.id, attack.troops, myPlayer.troops(), true);
|
||||
const barStrength = computeBarStrength(attack.troops, myPlayer.troops());
|
||||
this.ensureLabel(attack.id, attack.troops, true, barStrength);
|
||||
}
|
||||
|
||||
for (const [id] of this.labels) {
|
||||
@@ -153,8 +201,8 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
private ensureLabel(
|
||||
attackID: string,
|
||||
attackerTroops: number,
|
||||
defenderTroops: number,
|
||||
isIncoming: boolean,
|
||||
barStrength: number,
|
||||
) {
|
||||
let label = this.labels.get(attackID);
|
||||
if (!label) {
|
||||
@@ -163,15 +211,15 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
positions: [],
|
||||
isIncoming,
|
||||
attackerTroops,
|
||||
defenderTroops,
|
||||
barStrength,
|
||||
};
|
||||
this.labels.set(attackID, label);
|
||||
} else {
|
||||
label.attackerTroops = attackerTroops;
|
||||
label.defenderTroops = defenderTroops;
|
||||
label.barStrength = barStrength;
|
||||
}
|
||||
for (const el of label.elements) {
|
||||
this.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming);
|
||||
this.updateLabelContent(el, attackerTroops, barStrength);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +233,8 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
);
|
||||
this.container.style.transform = `translate(${screenPos.x}px, ${screenPos.y}px) scale(${this.transformHandler.scale})`;
|
||||
|
||||
// Hoist the per-frame label scale once; zoom is constant within a frame.
|
||||
const scale = this.labelScale();
|
||||
for (const label of this.labels.values()) {
|
||||
for (let i = 0; i < label.elements.length; i++) {
|
||||
const el = label.elements[i];
|
||||
@@ -196,9 +246,14 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
}
|
||||
|
||||
el.style.display = "inline-flex";
|
||||
// Centre the label on its world position and counter-scale so text
|
||||
// stays the same screen size regardless of zoom level.
|
||||
el.style.transform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%) scale(${1 / this.transformHandler.scale})`;
|
||||
// Centre the label on its world position; counter-scale keeps the
|
||||
// label at constant screen size while zoomed in, then it shrinks
|
||||
// (floored) as zoom drops below LABEL_FULL_SIZE_ZOOM.
|
||||
const transform = `translate(${pos.x}px, ${pos.y}px) translate(-50%, -50%) scale(${scale})`;
|
||||
if (this.lastTransform.get(el) !== transform) {
|
||||
el.style.transform = transform;
|
||||
this.lastTransform.set(el, transform);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,8 +264,8 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
lbl.elements.push(
|
||||
this.createLabelElement(
|
||||
lbl.attackerTroops,
|
||||
lbl.defenderTroops,
|
||||
lbl.isIncoming,
|
||||
lbl.barStrength,
|
||||
),
|
||||
);
|
||||
lbl.positions.push(null);
|
||||
@@ -222,16 +277,20 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
lbl.positions.pop();
|
||||
}
|
||||
|
||||
// Snap large jumps instantly; let the CSS transition handle small advances.
|
||||
alignClusterOrder(positions, lbl.positions);
|
||||
|
||||
// Snap teleport-sized jumps instantly; let the CSS transition handle the rest.
|
||||
for (let i = 0; i < positions.length; i++) {
|
||||
const old = lbl.positions[i];
|
||||
const next = positions[i];
|
||||
if (old && Math.hypot(next.x - old.x, next.y - old.y) > 50) {
|
||||
if (old && Math.hypot(next.x - old.x, next.y - old.y) > 200) {
|
||||
const el = lbl.elements[i];
|
||||
el.style.transition = "none";
|
||||
el.style.transform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%) scale(${1 / this.transformHandler.scale})`;
|
||||
const transform = `translate(${next.x}px, ${next.y}px) translate(-50%, -50%) scale(${this.labelScale()})`;
|
||||
el.style.transform = transform;
|
||||
this.lastTransform.set(el, transform);
|
||||
requestAnimationFrame(() => {
|
||||
el.style.transition = "transform 0.2s ease-out";
|
||||
el.style.transition = "transform 0.25s linear";
|
||||
});
|
||||
}
|
||||
lbl.positions[i] = next;
|
||||
@@ -245,33 +304,53 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
el.style.alignItems = "center";
|
||||
el.style.gap = "3px";
|
||||
el.style.whiteSpace = "nowrap";
|
||||
el.style.fontSize = "11px";
|
||||
el.style.fontSize = "14px";
|
||||
el.style.fontWeight = "bold";
|
||||
el.style.padding = "1px 4px";
|
||||
el.style.padding = "2px 5px";
|
||||
el.style.borderRadius = "3px";
|
||||
el.style.backgroundColor = "rgba(0,0,0,0.55)";
|
||||
el.style.backgroundColor = "rgba(0,0,0,0.85)";
|
||||
el.style.pointerEvents = "none";
|
||||
el.style.lineHeight = "1.3";
|
||||
el.style.transition = "transform 0.2s ease-out";
|
||||
el.style.transition = "transform 0.25s linear";
|
||||
el.style.width = "max-content";
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.style.width = "2px";
|
||||
bar.style.borderRadius = "1px";
|
||||
bar.style.alignSelf = "flex-end";
|
||||
bar.style.transition = "height 0.25s linear";
|
||||
el.appendChild(bar);
|
||||
|
||||
const icon = document.createElement("img");
|
||||
icon.style.width = "10px";
|
||||
icon.style.height = "10px";
|
||||
icon.style.width = "13px";
|
||||
icon.style.height = "13px";
|
||||
el.appendChild(icon);
|
||||
|
||||
const span = document.createElement("span");
|
||||
span.style.minWidth = "25px";
|
||||
el.appendChild(span);
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
private createLabelElement(
|
||||
attackerTroops: number,
|
||||
defenderTroops: number,
|
||||
isIncoming: boolean,
|
||||
barStrength: number,
|
||||
): HTMLDivElement {
|
||||
const el = this.labelTemplate.cloneNode(true) as HTMLDivElement;
|
||||
el.style.fontFamily = this.game.config().theme().font();
|
||||
this.updateLabelContent(el, attackerTroops, defenderTroops, isIncoming);
|
||||
const bar = el.children[0] as HTMLDivElement;
|
||||
const icon = el.children[1] as HTMLImageElement;
|
||||
const span = el.children[2] as HTMLSpanElement;
|
||||
icon.src = soldierIcon;
|
||||
icon.style.filter = isIncoming
|
||||
? INCOMING_ICON_FILTER
|
||||
: OUTGOING_ICON_FILTER;
|
||||
span.style.color = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR;
|
||||
span.textContent = renderTroops(attackerTroops);
|
||||
bar.style.backgroundColor = isIncoming ? INCOMING_COLOR : OUTGOING_COLOR;
|
||||
bar.style.height = `${barStrength * BAR_MAX_HEIGHT_PX}px`;
|
||||
this.container.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
@@ -279,20 +358,12 @@ export class AttackingTroopsOverlay implements Layer {
|
||||
private updateLabelContent(
|
||||
el: HTMLDivElement,
|
||||
attackerTroops: number,
|
||||
defenderTroops: number,
|
||||
isIncoming: boolean,
|
||||
barStrength: number,
|
||||
) {
|
||||
const icon = el.children[0] as HTMLImageElement;
|
||||
const span = el.children[1] as HTMLSpanElement;
|
||||
if (isIncoming) {
|
||||
icon.src = shieldIcon;
|
||||
span.style.color = troopDefenceColor(attackerTroops, defenderTroops);
|
||||
span.textContent = renderTroops(attackerTroops);
|
||||
} else {
|
||||
icon.src = swordIcon;
|
||||
span.style.color = troopAttackColor(attackerTroops, defenderTroops);
|
||||
span.textContent = renderTroops(attackerTroops);
|
||||
}
|
||||
const bar = el.children[0] as HTMLDivElement;
|
||||
const span = el.children[2] as HTMLSpanElement;
|
||||
span.textContent = renderTroops(attackerTroops);
|
||||
bar.style.height = `${barStrength * BAR_MAX_HEIGHT_PX}px`;
|
||||
}
|
||||
|
||||
private removeLabel(attackID: string) {
|
||||
|
||||
@@ -1,32 +1,126 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
troopAttackColor,
|
||||
troopDefenceColor,
|
||||
alignClusterOrder,
|
||||
computeBarStrength,
|
||||
computeLabelScale,
|
||||
} from "../../../../src/client/graphics/layers/AttackingTroopsOverlay";
|
||||
import { Cell } from "../../../../src/core/game/Game";
|
||||
|
||||
describe("troopAttackColor", () => {
|
||||
test("returns green when attacker has more troops", () => {
|
||||
expect(troopAttackColor(1000, 500)).toBe("#66ff66");
|
||||
describe("computeLabelScale", () => {
|
||||
test("counter-scales the zoom when above the full-size threshold", () => {
|
||||
// zoom = 2 → label rendered at 1/2 to stay at full screen size.
|
||||
expect(computeLabelScale(2)).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
test("returns amber when defender has more troops", () => {
|
||||
expect(troopAttackColor(500, 1000)).toBe("#ffbe3c");
|
||||
test("counter-scales exactly at the full-size threshold", () => {
|
||||
// zoom = 1.5 → label rendered at 1/1.5 ≈ 0.6667.
|
||||
expect(computeLabelScale(1.5)).toBeCloseTo(1 / 1.5);
|
||||
});
|
||||
|
||||
test("returns amber when troops are equal", () => {
|
||||
expect(troopAttackColor(500, 500)).toBe("#ffbe3c");
|
||||
test("rides the world transform between the floor and the threshold", () => {
|
||||
// Below the threshold, netScale = zoom / 1.5, so the factor is constant 1/1.5.
|
||||
expect(computeLabelScale(1)).toBeCloseTo(1 / 1.5);
|
||||
expect(computeLabelScale(0.9)).toBeCloseTo(1 / 1.5);
|
||||
});
|
||||
|
||||
test("floor engages exactly at zoom = 0.75 (LABEL_MIN_SCREEN_SCALE * LABEL_FULL_SIZE_ZOOM)", () => {
|
||||
expect(computeLabelScale(0.75)).toBeCloseTo(1 / 1.5);
|
||||
});
|
||||
|
||||
test("grows in screen space when zoomed out past the floor", () => {
|
||||
// zoom = 0.5 → netScale clamped to 0.5, factor = 0.5 / 0.5 = 1.
|
||||
expect(computeLabelScale(0.5)).toBeCloseTo(1);
|
||||
// zoom = 0.25 → factor = 0.5 / 0.25 = 2.
|
||||
expect(computeLabelScale(0.25)).toBeCloseTo(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("troopDefenceColor", () => {
|
||||
test("returns red when attacker has more troops than defender", () => {
|
||||
expect(troopDefenceColor(1000, 500)).toBe("#ff4444");
|
||||
describe("computeBarStrength", () => {
|
||||
test("equal troops sit at the midpoint", () => {
|
||||
// 1000 vs 1000 → ratio 1, divided by full-height ratio of 2 → 0.5.
|
||||
expect(computeBarStrength(1000, 1000)).toBeCloseTo(0.5);
|
||||
});
|
||||
|
||||
test("returns orange when defender has more troops", () => {
|
||||
expect(troopDefenceColor(500, 1000)).toBe("#ff9944");
|
||||
test("attacker with no troops yields a zero-height bar", () => {
|
||||
expect(computeBarStrength(0, 1000)).toBe(0);
|
||||
});
|
||||
|
||||
test("returns orange when troops are equal", () => {
|
||||
expect(troopDefenceColor(500, 500)).toBe("#ff9944");
|
||||
test("scales linearly between zero and the full-height threshold", () => {
|
||||
// 500 vs 1000 → ratio 0.5 → 0.25.
|
||||
expect(computeBarStrength(500, 1000)).toBeCloseTo(0.25);
|
||||
// 1500 vs 1000 → ratio 1.5 → 0.75.
|
||||
expect(computeBarStrength(1500, 1000)).toBeCloseTo(0.75);
|
||||
});
|
||||
|
||||
test("clamps at full height when attacker has 2× the opposition", () => {
|
||||
expect(computeBarStrength(2000, 1000)).toBeCloseTo(1);
|
||||
expect(computeBarStrength(10_000, 1000)).toBeCloseTo(1);
|
||||
});
|
||||
|
||||
test("returns full height when the opposing side has no troops", () => {
|
||||
// Avoids division-by-zero: an undefended target is maximum strength.
|
||||
expect(computeBarStrength(500, 0)).toBe(1);
|
||||
expect(computeBarStrength(0, 0)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("alignClusterOrder", () => {
|
||||
const c = (x: number, y: number) => new Cell(x, y);
|
||||
|
||||
test("preserves order when direct mapping is closer", () => {
|
||||
const next = [c(10, 10), c(100, 100)];
|
||||
const prev = [c(12, 11), c(98, 102)];
|
||||
alignClusterOrder(next, prev);
|
||||
expect(next[0].x).toBe(10);
|
||||
expect(next[1].x).toBe(100);
|
||||
});
|
||||
|
||||
test("swaps when the worker reordered same-size clusters", () => {
|
||||
// prev[0] is near (10,10), prev[1] is near (100,100); the worker returned
|
||||
// them in the opposite order. Expect swap so each label sticks to its front.
|
||||
const next = [c(101, 99), c(11, 12)];
|
||||
const prev = [c(10, 10), c(100, 100)];
|
||||
alignClusterOrder(next, prev);
|
||||
expect(next[0].x).toBe(11);
|
||||
expect(next[1].x).toBe(101);
|
||||
});
|
||||
|
||||
test("does not swap on a tie (strict less-than)", () => {
|
||||
const next = [c(0, 0), c(10, 0)];
|
||||
const prev = [c(5, 0), c(5, 0)];
|
||||
alignClusterOrder(next, prev);
|
||||
expect(next[0].x).toBe(0);
|
||||
expect(next[1].x).toBe(10);
|
||||
});
|
||||
|
||||
test("no-op when fewer than two new positions", () => {
|
||||
const single = [c(99, 99)];
|
||||
alignClusterOrder(single, [c(0, 0), c(1000, 1000)]);
|
||||
expect(single[0].x).toBe(99);
|
||||
|
||||
const empty: Cell[] = [];
|
||||
alignClusterOrder(empty, [c(0, 0), c(1000, 1000)]);
|
||||
expect(empty.length).toBe(0);
|
||||
});
|
||||
|
||||
test("no-op when either previous slot is null (initial render)", () => {
|
||||
const next = [c(100, 100), c(0, 0)];
|
||||
alignClusterOrder(next, [null, c(0, 0)]);
|
||||
expect(next[0].x).toBe(100);
|
||||
expect(next[1].x).toBe(0);
|
||||
|
||||
alignClusterOrder(next, [c(0, 0), null]);
|
||||
expect(next[0].x).toBe(100);
|
||||
expect(next[1].x).toBe(0);
|
||||
|
||||
alignClusterOrder(next, [null, null]);
|
||||
expect(next[0].x).toBe(100);
|
||||
expect(next[1].x).toBe(0);
|
||||
});
|
||||
|
||||
test("no-op when more than two new positions (assumed cap)", () => {
|
||||
const next = [c(100, 0), c(0, 0), c(50, 0)];
|
||||
alignClusterOrder(next, [c(0, 0), c(100, 0)]);
|
||||
expect(next.map((p) => p.x)).toEqual([100, 0, 50]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user