Files
OpenFrontIO/src/client/graphics/layers/RadialMenu.ts
T
Mattia Migliorini 90204f6628 Add alliance renewal action to Radial Menu (#3148)
## Description:

The following PR replaces the (disabled) alliance request button with an
alliance extension/renewal button when the alliance with the target
player is expiring.

Agreeing to renewal via radial menu also hides the message in the
EventsDisplay.

<img width="369" height="364" alt="image"
src="https://github.com/user-attachments/assets/d8040f5c-ad7b-47d0-852f-925ecbf273a8"
/>


https://github.com/user-attachments/assets/aa589edf-6505-46bf-88a3-aa4c2df9137f

Icon size adjusted:

<img width="294" height="252" alt="image"
src="https://github.com/user-attachments/assets/7ca63500-b1fb-427b-965c-cf121a5213da"
/>

## 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:

deshack_82603

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-19 19:47:57 -06:00

1415 lines
42 KiB
TypeScript

import * as d3 from "d3";
import { EventBus, GameEvent } from "../../../core/EventBus";
import { CloseViewEvent } from "../../InputHandler";
import { getSvgAspectRatio, translateText } from "../../Utils";
import { Layer } from "./Layer";
import {
CenterButtonElement,
MenuElement,
MenuElementParams,
TooltipKey,
} from "./RadialMenuElements";
import backIcon from "/images/BackIconWhite.svg?url";
function resolveColor(
item: MenuElement,
params: MenuElementParams | null,
): string | undefined {
if (typeof item.color === "function") {
return params ? item.color(params) : undefined;
}
return item.color;
}
export class CloseRadialMenuEvent implements GameEvent {
constructor() {}
}
export interface TooltipItem {
text: string;
className: string;
}
export interface RadialMenuConfig {
menuSize?: number;
submenuScale?: number;
centerButtonSize?: number;
iconSize?: number;
centerIconSize?: number;
disabledColor?: string;
menuTransitionDuration?: number;
mainMenuInnerRadius?: number;
centerButtonIcon?: string;
maxNestedLevels?: number;
innerRadiusIncrement?: number;
tooltipStyle?: string;
}
type CenterButtonState = "default" | "back";
type RequiredRadialMenuConfig = Required<RadialMenuConfig>;
export class RadialMenu implements Layer {
private menuElement: d3.Selection<HTMLDivElement, unknown, null, undefined>;
private tooltipElement: HTMLDivElement | null = null;
private isVisible: boolean = false;
private currentLevel: number = 0; // Current menu level (0 = main menu, 1 = submenu, etc.)
private menuStack: MenuElement[][] = []; // Stack to track menu navigation history
private currentMenuItems: MenuElement[] = []; // Current active menu items (changes based on level)
private readonly config: RequiredRadialMenuConfig;
private readonly backIconSize: number;
private centerButtonState: CenterButtonState = "default";
private isTransitioning: boolean = false;
private lastHideTime: number = 0;
private reopenCooldownMs: number = 300;
private anchorX = 0;
private anchorY = 0;
private menuGroups: Map<
number,
d3.Selection<SVGGElement, unknown, null, undefined>
> = new Map();
private menuPaths: Map<
string,
d3.Selection<SVGPathElement, unknown, null, undefined>
> = new Map();
private menuIcons: Map<
string,
d3.Selection<SVGImageElement, unknown, null, undefined>
> = new Map();
private selectedItemId: string | null = null;
private submenuHoverTimeout: number | null = null;
private backButtonHoverTimeout: number | null = null;
private navigationInProgress: boolean = false;
private originalCenterButtonIcon: string = "";
private readonly defaultCenterButtonColor = "#2c3e50";
private centerButtonColor: string;
private centerButtonIconSize: number;
private params: MenuElementParams | null = null;
constructor(
private eventBus: EventBus,
private rootMenu: MenuElement,
private centerButtonElement: CenterButtonElement,
config: RadialMenuConfig = {},
) {
this.config = {
menuSize: config.menuSize ?? 190,
submenuScale: config.submenuScale ?? 1.5,
centerButtonSize: config.centerButtonSize ?? 30,
iconSize: config.iconSize ?? 32,
centerIconSize: config.centerIconSize ?? 48,
disabledColor: config.disabledColor ?? d3.rgb(128, 128, 128).toString(),
menuTransitionDuration: config.menuTransitionDuration ?? 300,
mainMenuInnerRadius: config.mainMenuInnerRadius ?? 40,
centerButtonIcon: config.centerButtonIcon ?? "",
maxNestedLevels: config.maxNestedLevels ?? 3,
innerRadiusIncrement: config.innerRadiusIncrement ?? 20,
tooltipStyle: config.tooltipStyle ?? "",
};
this.originalCenterButtonIcon = this.config.centerButtonIcon;
this.backIconSize = this.config.centerIconSize * 0.8;
this.centerButtonColor = this.defaultCenterButtonColor;
this.centerButtonIconSize = this.config.centerIconSize;
}
init() {
this.createMenuElement();
this.createTooltipElement();
this.eventBus.on(CloseViewEvent, (e) => {
this.hideRadialMenu();
});
}
private createMenuElement() {
// Create an overlay to catch clicks outside the menu
this.menuElement = d3
.select(document.body)
.append("div")
.attr("class", "radial-menu-container")
.style("position", "fixed")
.style("display", "none")
.style("z-index", "9999")
.style("touch-action", "none")
.style("top", "0")
.style("left", "0")
.style("width", "100vw")
.style("height", "100vh")
.on("click", () => {
this.hideRadialMenu();
this.eventBus.emit(new CloseRadialMenuEvent());
})
.on("contextmenu", (e) => {
e.preventDefault();
this.hideRadialMenu();
this.eventBus.emit(new CloseRadialMenuEvent());
});
// Calculate the total svg size needed for all potential nested menus
const totalSize =
this.config.menuSize *
Math.pow(this.config.submenuScale, this.config.maxNestedLevels - 1);
const svg = this.menuElement
.append("svg")
.attr("width", totalSize)
.attr("height", totalSize)
.style("position", "absolute")
.style("top", "50%")
.style("left", "50%")
.style("transform", "translate(-50%, -50%)")
.style("pointer-events", "all")
.on("click", (event) => this.hideRadialMenu());
const container = svg
.append("g")
.attr("class", "menu-container")
.attr("transform", `translate(${totalSize / 2},${totalSize / 2})`);
// Add glow filter for hover effects
const defs = svg.append("defs");
const filter = defs.append("filter").attr("id", "glow");
filter
.append("feGaussianBlur")
.attr("stdDeviation", "2")
.attr("result", "coloredBlur");
const feMerge = filter.append("feMerge");
feMerge.append("feMergeNode").attr("in", "coloredBlur");
feMerge.append("feMergeNode").attr("in", "SourceGraphic");
const centerButton = container.append("g").attr("class", "center-button");
centerButton
.append("circle")
.attr("class", "center-button-hitbox")
.attr("r", this.config.centerButtonSize)
.attr("fill", "transparent")
.style("cursor", "pointer")
.on("click", (event) => {
event.stopPropagation();
this.handleCenterButtonClick();
})
.on("touchstart", (event: Event) => {
event.preventDefault();
event.stopPropagation();
this.handleCenterButtonClick();
})
.on("mouseover", () => this.onCenterButtonHover(true))
.on("mouseout", () => this.onCenterButtonHover(false));
centerButton
.append("circle")
.attr("class", "center-button-visible")
.attr("r", this.config.centerButtonSize)
.attr("fill", this.centerButtonColor)
.style("pointer-events", "none");
centerButton
.append("image")
.attr("class", "center-button-icon")
.attr("xlink:href", this.config.centerButtonIcon)
.attr("width", this.centerButtonIconSize)
.attr("height", this.centerButtonIconSize)
.attr("x", -this.centerButtonIconSize / 2)
.attr("y", -this.centerButtonIconSize / 2)
.style("pointer-events", "none");
}
private createTooltipElement() {
this.tooltipElement = document.createElement("div");
this.tooltipElement.className = "radial-tooltip";
this.tooltipElement.style.position = "absolute";
this.tooltipElement.style.pointerEvents = "none";
this.tooltipElement.style.background = "rgba(0, 0, 0, 0.7)";
this.tooltipElement.style.color = "white";
this.tooltipElement.style.padding = "6px 10px";
this.tooltipElement.style.borderRadius = "6px";
this.tooltipElement.style.fontSize = "12px";
this.tooltipElement.style.zIndex = "10000";
this.tooltipElement.style.maxWidth = "250px";
this.tooltipElement.style.display = "none";
document.body.appendChild(this.tooltipElement);
const style = document.createElement("style");
style.textContent = `
.radial-tooltip .title {
font-weight: bold;
font-size: 14px;
margin-bottom: 4px;
}
${this.config.tooltipStyle}
`;
document.head.appendChild(style);
}
private getInnerRadiusForLevel(level: number): number {
return level === 0 ? 40 : 50 + 25;
}
private getOuterRadiusForLevel(level: number): number {
const innerRadius = this.getInnerRadiusForLevel(level);
let arcWidth = 55;
if (level !== 0) {
arcWidth = 65;
}
return innerRadius + arcWidth;
}
private renderMenuItems(items: MenuElement[], level: number) {
const container = this.menuElement.select(".menu-container");
container.selectAll(`.menu-level-${level}`).remove();
const menuGroup = container
.append("g")
.attr("class", `menu-level-${level}`);
// Set initial animation styles only for submenus (level > 0)
if (level === 0) {
// Main menu appears immediately without animation
menuGroup.style("opacity", 1).style("transform", "scale(1)");
} else {
// Submenus get the expansion animation
menuGroup.style("opacity", 0).style("transform", "scale(0.5)");
}
this.menuGroups.set(level, menuGroup as any);
const offset = -Math.PI / items.length;
const pie = d3
.pie<MenuElement>()
.value(() => 1)
.padAngle(0.03)
.startAngle(offset)
.endAngle(2 * Math.PI + offset);
const innerRadius = this.getInnerRadiusForLevel(level);
const outerRadius = this.getOuterRadiusForLevel(level);
const arc = d3
.arc<d3.PieArcDatum<MenuElement>>()
.innerRadius(innerRadius)
.outerRadius(outerRadius);
const arcs = menuGroup
.selectAll(".menu-item")
.data(pie(items))
.enter()
.append("g")
.attr("class", "menu-item-group");
this.renderPaths(arcs, arc, level);
this.setupEventHandlers(arcs, level);
this.renderIconsAndText(arcs, arc);
this.setupAnimations(menuGroup);
return menuGroup;
}
private renderPaths(
arcs: d3.Selection<
SVGGElement,
d3.PieArcDatum<MenuElement>,
SVGGElement,
unknown
>,
arc: d3.Arc<any, d3.PieArcDatum<MenuElement>>,
level: number,
) {
arcs
.append("path")
.attr("class", "menu-item-path")
.attr("d", arc)
.attr("fill", (d) => {
const disabled = this.params === null || d.data.disabled(this.params);
const color = disabled
? this.config.disabledColor
: (resolveColor(d.data, this.params) ?? "#333333");
const opacity = disabled ? 0.5 : 0.7;
if (d.data.id === this.selectedItemId && this.currentLevel > level) {
return color;
}
return d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color;
})
.attr("stroke", "#ffffff")
.attr("stroke-width", "2")
.style("cursor", (d) =>
this.params === null || d.data.disabled(this.params)
? "not-allowed"
: "pointer",
)
.style("opacity", (d) =>
this.params === null || d.data.disabled(this.params) ? 0.5 : 1,
)
.style(
"transition",
`filter ${this.config.menuTransitionDuration / 2}ms, stroke-width ${
this.config.menuTransitionDuration / 2
}ms, fill ${this.config.menuTransitionDuration / 2}ms`,
)
.attr("data-id", (d) => d.data.id);
// Timer gradient for items with timerFraction
arcs.each((d) => {
if (d.data.timerFraction && this.params) {
const fraction = d.data.timerFraction(this.params);
const disabled = this.params === null || d.data.disabled(this.params);
const baseColor = disabled
? this.config.disabledColor
: (resolveColor(d.data, this.params) ?? "#333333");
const opacity = disabled ? 0.5 : 0.7;
const normalColor =
d3.color(baseColor)?.copy({ opacity: opacity })?.toString() ??
baseColor;
const interpolated = d3.color(
d3.interpolateRgb(baseColor, "white")(0.4),
);
const fadedColor =
interpolated?.copy({ opacity })?.toString() ?? normalColor;
const gradientId = `timer-gradient-${d.data.id}`;
const defs = this.menuElement.select("defs");
defs.select(`#${gradientId}`).remove();
const offset = 1 - fraction;
const gradient = defs
.append("linearGradient")
.attr("id", gradientId)
.attr("x1", 0)
.attr("y1", 0)
.attr("x2", 0)
.attr("y2", 1);
gradient
.append("stop")
.attr("class", "timer-stop-faded")
.attr("offset", offset)
.attr("stop-color", fadedColor);
gradient
.append("stop")
.attr("class", "timer-stop-normal")
.attr("offset", offset)
.attr("stop-color", normalColor);
const path = d3.select(`path[data-id="${d.data.id}"]`);
path.attr("fill", `url(#${gradientId})`);
}
});
arcs.each((d) => {
const pathId = d.data.id;
const path = d3.select(`path[data-id="${pathId}"]`);
this.menuPaths.set(pathId, path as any);
if (
pathId === this.selectedItemId &&
level === 0 &&
this.currentLevel > 0
) {
path.attr("filter", "url(#glow)");
path.attr("stroke-width", "3");
const color =
this.params === null || d.data.disabled(this.params)
? this.config.disabledColor
: (resolveColor(d.data, this.params) ?? "#333333");
path.attr("fill", color);
}
});
// Disable pointer events on previous menu levels
this.menuGroups.forEach((group, menuLevel) => {
if (menuLevel < this.currentLevel) {
group.selectAll("path").each(function () {
const pathElement = d3.select(this);
pathElement.style("pointer-events", "none");
});
} else if (menuLevel === this.currentLevel) {
group.selectAll("path").style("pointer-events", "auto");
}
});
}
private setupEventHandlers(
arcs: d3.Selection<
SVGGElement,
d3.PieArcDatum<MenuElement>,
SVGGElement,
unknown
>,
level: number,
) {
const onHover = (d: d3.PieArcDatum<MenuElement>, path: any) => {
const disabled = this.params === null || d.data.disabled(this.params);
if (d.data.tooltipItems && d.data.tooltipItems.length > 0) {
this.showTooltip(d.data.tooltipItems);
} else if (d.data.tooltipKeys && d.data.tooltipKeys.length > 0) {
this.showTooltip(d.data.tooltipKeys);
}
if (
disabled ||
(this.currentLevel > 0 && this.currentLevel !== level) ||
this.navigationInProgress
) {
return;
}
path.attr("filter", "url(#glow)");
path.attr("stroke-width", "3");
};
const onMouseOut = (d: d3.PieArcDatum<MenuElement>, path: any) => {
const disabled = this.params === null || d.data.disabled(this.params);
if (this.submenuHoverTimeout !== null) {
window.clearTimeout(this.submenuHoverTimeout);
this.submenuHoverTimeout = null;
}
this.hideTooltip();
if (
disabled ||
(this.currentLevel > 0 &&
level === 0 &&
d.data.id === this.selectedItemId)
)
return;
path.attr("filter", null);
path.attr("stroke-width", "2");
const color = disabled
? this.config.disabledColor
: (resolveColor(d.data, this.params) ?? "#333333");
const opacity = disabled ? 0.5 : 0.7;
if (d.data.timerFraction) {
path.attr("fill", `url(#timer-gradient-${d.data.id})`);
} else {
path.attr(
"fill",
d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color,
);
}
};
const onClick = (d: d3.PieArcDatum<MenuElement>, event: Event) => {
event.stopPropagation();
if (
this.params === null ||
d.data.disabled(this.params) ||
this.navigationInProgress
)
return;
if (
this.currentLevel > 0 &&
level === 0 &&
d.data.id !== this.selectedItemId
)
return;
const subMenu = d.data.subMenu?.(this.params);
if (subMenu && subMenu.length > 0) {
this.navigationInProgress = true;
this.selectedItemId = d.data.id;
this.navigateToSubMenu(subMenu);
this.updateCenterButtonState("back");
} else {
d.data.action?.(this.params);
// Force transition state to false to ensure menu hides
this.isTransitioning = false;
this.hideRadialMenu();
}
};
function handleMouseMove(event: MouseEvent) {
const tooltipEl = document.querySelector(
".radial-tooltip",
) as HTMLElement;
if (tooltipEl && tooltipEl.style.display !== "none") {
tooltipEl.style.left = event.pageX + 10 + "px";
tooltipEl.style.top = event.pageY + 10 + "px";
}
}
arcs.each((d) => {
const pathId = d.data.id;
const path = d3.select(`path[data-id="${pathId}"]`);
path.on("mouseover", function () {
onHover(d, path);
});
path.on("mouseout", function () {
onMouseOut(d, path);
});
path.on("mousemove", function (event) {
handleMouseMove(event as MouseEvent);
});
path.on("click", function (event) {
onClick(d, event);
});
path.on("touchstart", function (event) {
event.preventDefault();
event.stopPropagation();
onClick(d, event);
});
});
}
private isItemDisabled(item: MenuElement): boolean {
return (
this.params === null ||
this.params.game.inSpawnPhase() ||
item.disabled(this.params)
);
}
private renderIconsAndText(
arcs: d3.Selection<
SVGGElement,
d3.PieArcDatum<MenuElement>,
SVGGElement,
unknown
>,
arc: d3.Arc<any, d3.PieArcDatum<MenuElement>>,
) {
arcs
.append("g")
.attr("class", "menu-item-content")
.style("pointer-events", "none")
.attr("data-id", (d) => d.data.id)
.attr("data-cx", (d) => arc.centroid(d)[0].toString())
.attr("data-cy", (d) => arc.centroid(d)[1].toString())
.each((d) => {
const contentId = d.data.id;
const content = d3.select(`g[data-id="${contentId}"]`);
const disabled = this.isItemDisabled(d.data);
if (d.data.renderType && this.params) {
const stateKey = this.getStateKeyByType(
d.data.renderType,
disabled,
this.params,
);
if (stateKey) {
content.attr("data-prev-state", stateKey);
}
if (d.data.renderType === "allyExtend") {
this.renderAllyExtendIcon(
content.node()! as SVGGElement,
arc.centroid(d)[0],
arc.centroid(d)[1],
this.config.iconSize,
disabled,
this.params,
d.data.icon,
);
}
} else if (d.data.text) {
content
.append("text")
.attr("text-anchor", "middle")
.attr("dominant-baseline", "central")
.attr("x", arc.centroid(d)[0])
.attr("y", arc.centroid(d)[1])
.attr("fill", "white")
.attr("font-size", d.data.fontSize ?? "12px")
.attr("font-family", "Arial, sans-serif")
.style("opacity", disabled ? 0.5 : 1)
.text(d.data.text);
} else {
const imgSel = content
.append("image")
.attr("xlink:href", d.data.icon!)
.attr("width", this.config.iconSize)
.attr("height", this.config.iconSize)
.attr("x", arc.centroid(d)[0] - this.config.iconSize / 2)
.attr("y", arc.centroid(d)[1] - this.config.iconSize / 2)
.attr("opacity", disabled ? 0.5 : 1);
getSvgAspectRatio(d.data.icon!).then((aspect) => {
if (!aspect || aspect === 1) return;
let width = this.config.iconSize;
let height = this.config.iconSize;
const biggerLength = Math.round(width * aspect);
if (aspect > 1) {
width = biggerLength;
} else {
height = biggerLength;
}
imgSel
.attr("width", width)
.attr("height", height)
.attr("x", arc.centroid(d)[0] - width / 2)
.attr("y", arc.centroid(d)[1] - height / 2);
});
if (this.params && d.data.cooldown?.(this.params)) {
const cooldown = Math.ceil(d.data.cooldown?.(this.params));
content
.append("text")
.attr("class", `cooldown-text`)
.text(cooldown + "s")
.attr("fill", "white")
.attr("opacity", disabled ? 0.5 : 1)
.attr("font-size", "14px")
.attr("font-weight", "bold")
.attr("x", arc.centroid(d)[0] - this.config.iconSize / 4)
.attr("y", arc.centroid(d)[1] + this.config.iconSize / 2 + 7);
}
}
this.menuIcons.set(contentId, content as any);
});
}
private setupAnimations(
menuGroup: d3.Selection<SVGGElement, unknown, null, undefined>,
) {
menuGroup
.transition()
.duration(this.config.menuTransitionDuration * 0.8)
.style("opacity", 1)
.style("transform", "scale(1)")
.on("start", () => {
this.isTransitioning = true;
})
.on("end", () => {
this.isTransitioning = false;
});
}
private navigateToSubMenu(children: MenuElement[]) {
this.isTransitioning = true;
this.menuStack.push(this.currentMenuItems);
this.currentMenuItems = children;
this.currentLevel++;
this.clampAndSetMenuPositionForLevel(this.currentLevel);
this.renderMenuItems(this.currentMenuItems, this.currentLevel);
this.updateMenuGroupVisibility();
this.animatePreviousMenu();
}
private updateMenuGroupVisibility() {
this.updateMenuVisibility("forward");
}
private updateMenuVisibility(direction: "forward" | "backward" = "backward") {
this.menuGroups.forEach((menuGroup, level) => {
if (level === this.currentLevel) {
// Current level - always visible and interactive
menuGroup.style("display", "block");
menuGroup
.transition()
.duration(this.config.menuTransitionDuration * 0.8)
.style("transform", "scale(1)")
.style("opacity", 1);
// Enable pointer events for current level
menuGroup.selectAll("path").style("pointer-events", "auto");
} else if (level === this.currentLevel - 1 && this.currentLevel > 0) {
// Previous level - visible but scaled down
menuGroup.style("display", "block");
menuGroup
.transition()
.duration(this.config.menuTransitionDuration * 0.8)
.style(
"transform",
`scale(${this.currentLevel === 1 ? "0.65" : "0.5"})`,
)
.style("opacity", 0.8);
// Disable pointer events for previous level when going forward
if (direction === "forward") {
menuGroup.selectAll("path").each(function () {
const pathElement = d3.select(this);
pathElement.style("pointer-events", "none");
});
}
} else if (level !== this.currentLevel + 1) {
// Hide all other levels
menuGroup
.transition()
.duration(this.config.menuTransitionDuration * 0.5)
.style("transform", "scale(0.5)")
.style("opacity", 0)
.on("end", function () {
d3.select(this).style("display", "none");
});
}
});
}
private animatePreviousMenu() {
const container = this.menuElement.select(".menu-container");
const currentMenu = container.select(
`.menu-level-${this.currentLevel - 1}`,
);
currentMenu
.transition()
.duration(this.config.menuTransitionDuration * 0.8)
.style("transform", `scale(${this.currentLevel === 1 ? "0.65" : "0.5"})`)
.style("opacity", 0.8)
.on("end", () => {
this.navigationInProgress = false;
});
}
private navigateBack() {
if (this.menuStack.length === 0) {
return;
}
this.isTransitioning = true;
this.updateMenuLevels();
this.clampAndSetMenuPositionForLevel(this.currentLevel);
this.clearSelectedItemHoverState();
this.updateMenuVisibility("backward");
this.animateMenuTransitions();
}
private updateMenuLevels() {
const previousItems = this.menuStack.pop();
const previousLevel = this.currentLevel - 1;
this.currentLevel = previousLevel;
if (previousLevel === 0) {
this.selectedItemId = null;
}
this.currentMenuItems = previousItems ?? [];
if (this.currentLevel === 0) {
this.updateCenterButtonState("default");
}
}
private clearSelectedItemHoverState() {
// Clear the hover state on the item that opened the submenu
if (this.selectedItemId) {
const selectedPath = this.menuPaths.get(this.selectedItemId);
if (selectedPath) {
selectedPath.attr("filter", null);
selectedPath.attr("stroke-width", "2");
}
}
// Use refresh() to update all item appearances consistently
this.refresh();
}
private animateMenuTransitions() {
const container = this.menuElement.select(".menu-container");
const currentSubmenu = container.select(
`.menu-level-${this.currentLevel + 1}`,
);
const previousMenu = container.select(`.menu-level-${this.currentLevel}`);
// Animate the current submenu (sliding out)
currentSubmenu
.transition()
.duration(this.config.menuTransitionDuration * 0.8)
.style("transform", "scale(0.5)")
.style("opacity", 0)
.on("end", function () {
d3.select(this).remove();
});
// Handle previous menu animation
if (previousMenu.empty()) {
this.renderAndAnimateNewMenu();
} else {
this.animateExistingMenu(previousMenu);
}
}
private renderAndAnimateNewMenu() {
const menu = this.renderMenuItems(this.currentMenuItems, this.currentLevel);
menu
.style("transform", "scale(0.8)")
.style("opacity", 0.3)
.transition()
.duration(this.config.menuTransitionDuration * 0.8)
.style("transform", "scale(1)")
.style("opacity", 1)
.on("end", () => {
this.isTransitioning = false;
this.navigationInProgress = false;
});
}
private animateExistingMenu(
previousMenu: d3.Selection<any, unknown, null, undefined>,
) {
previousMenu
.transition()
.duration(this.config.menuTransitionDuration * 0.8)
.style("transform", "scale(1)")
.style("opacity", 1)
.on("end", () => {
this.isTransitioning = false;
this.navigationInProgress = false;
});
previousMenu.selectAll("path").style("pointer-events", "auto");
}
public showRadialMenu(x: number, y: number) {
if (!this.isReopeningAllowed()) return;
this.resetMenu();
this.isTransitioning = false;
this.selectedItemId = null;
this.anchorX = x;
this.anchorY = y;
this.menuElement.style("display", "block");
this.clampAndSetMenuPositionForLevel(this.currentLevel);
this.isVisible = true;
this.renderMenuItems(this.currentMenuItems, this.currentLevel);
this.onCenterButtonHover(true);
window.addEventListener("resize", this.handleResize);
}
public hideRadialMenu() {
if (!this.isVisible) {
return;
}
// Force transition state to false to ensure menu hides
this.isTransitioning = false;
this.menuElement.style("display", "none");
this.isVisible = false;
this.selectedItemId = null;
this.hideTooltip();
this.resetMenu();
this.isTransitioning = false;
this.menuGroups.clear();
this.menuPaths.clear();
this.menuIcons.clear();
this.lastHideTime = Date.now();
window.removeEventListener("resize", this.handleResize);
}
private handleCenterButtonClick() {
if (this.centerButtonState === "default") {
if (this.params && this.isCenterButtonEnabled()) {
this.centerButtonElement?.action(this.params);
}
return;
}
if (this.centerButtonState === "back") {
this.navigationInProgress = true;
this.navigateBack();
return;
}
}
public disableAllButtons() {
this.updateCenterButtonState("default");
this.refresh();
}
public updateCenterButtonState(state: CenterButtonState) {
this.centerButtonState = state;
if (state === "back") {
const backButtonSize = this.config.centerButtonSize * 0.8; // Make back button 20% smaller
this.menuElement
.select(".center-button-hitbox")
.transition()
.duration(0)
.attr("r", backButtonSize);
this.menuElement
.select(".center-button-visible")
.transition()
.duration(0)
.attr("r", backButtonSize);
const backIconImg = this.menuElement.select(".center-button-icon");
backIconImg
.attr("xlink:href", backIcon)
.attr("width", this.backIconSize)
.attr("height", this.backIconSize)
.attr("x", -this.backIconSize / 2)
.attr("y", -this.backIconSize / 2);
}
if (state === "default") {
// Restore original button size
this.menuElement
.select(".center-button-hitbox")
.transition()
.duration(0)
.attr("r", this.config.centerButtonSize);
this.menuElement
.select(".center-button-visible")
.transition()
.duration(0)
.attr("r", this.config.centerButtonSize);
const iconImg = this.menuElement.select(".center-button-icon");
iconImg
.attr("xlink:href", this.originalCenterButtonIcon)
.attr("width", this.centerButtonIconSize)
.attr("height", this.centerButtonIconSize)
.attr("x", -this.centerButtonIconSize / 2)
.attr("y", -this.centerButtonIconSize / 2);
}
const centerButton = this.menuElement.select(".center-button");
const enabled = this.isCenterButtonEnabled();
centerButton
.select(".center-button-hitbox")
.style("cursor", enabled ? "pointer" : "not-allowed");
// Use default color for back button, otherwise use the current center button color
const buttonColor =
state === "back" ? this.defaultCenterButtonColor : this.centerButtonColor;
centerButton
.select(".center-button-visible")
.attr("fill", enabled ? buttonColor : "#999999");
centerButton
.select(".center-button-icon")
.style("opacity", enabled ? 1 : 0.5);
}
private isCenterButtonEnabled(): boolean {
// Back button should always be enabled when in submenu levels
if (this.currentLevel > 0) {
return true;
}
if (this.params && this.centerButtonElement) {
return !this.centerButtonElement.disabled(this.params);
}
return false;
}
private onCenterButtonHover(isHovering: boolean) {
if (!this.isCenterButtonEnabled()) return;
const scale = isHovering ? 1.2 : 1;
this.menuElement
.select(".center-button-hitbox")
.transition()
.duration(200)
.attr("r", this.config.centerButtonSize * scale);
this.menuElement
.select(".center-button-visible")
.transition()
.duration(200)
.attr("r", this.config.centerButtonSize * scale);
}
public isMenuVisible(): boolean {
return this.isVisible;
}
public getCurrentLevel(): number {
return this.currentLevel;
}
public setParams(params: MenuElementParams) {
this.params = params;
}
public getDefaultCenterIconSize(): number {
return this.config.centerIconSize;
}
public setCenterButtonAppearance(
icon: string,
color?: string,
iconSize?: number,
) {
this.originalCenterButtonIcon = icon;
this.centerButtonColor = color ?? this.defaultCenterButtonColor;
this.centerButtonIconSize = iconSize ?? this.config.centerIconSize;
if (!this.menuElement) return;
const iconImg = this.menuElement.select(".center-button-icon");
iconImg
.attr("xlink:href", icon)
.attr("width", this.centerButtonIconSize)
.attr("height", this.centerButtonIconSize)
.attr("x", -this.centerButtonIconSize / 2)
.attr("y", -this.centerButtonIconSize / 2);
this.menuElement
.select(".center-button-visible")
.attr(
"fill",
this.isCenterButtonEnabled() ? this.centerButtonColor : "#999999",
);
this.updateCenterButtonState(this.centerButtonState);
}
private findMenuItem(id: string): MenuElement | undefined {
return this.currentMenuItems.find((item) => item.id === id);
}
private resetMenu() {
this.currentLevel = 0;
this.menuStack = [];
this.currentMenuItems = this.rootMenu.subMenu!(this.params!);
this.navigationInProgress = false;
this.menuGroups.clear();
this.menuPaths.clear();
this.menuIcons.clear();
const menuContainer = this.menuElement?.select(".menu-container");
if (menuContainer) {
menuContainer.selectAll("[class^='menu-level-']").remove();
}
this.updateCenterButtonState("default");
if (this.submenuHoverTimeout !== null) {
window.clearTimeout(this.submenuHoverTimeout);
this.submenuHoverTimeout = null;
}
if (this.backButtonHoverTimeout !== null) {
window.clearTimeout(this.backButtonHoverTimeout);
this.backButtonHoverTimeout = null;
}
}
public refreshMenu() {
if (!this.isVisible) return;
this.renderMenuItems(this.currentMenuItems, this.currentLevel);
}
public refresh() {
if (!this.isVisible || !this.params) return;
// Refresh the disabled state of all menu items
this.menuPaths.forEach((path, itemId) => {
const item = this.findMenuItem(itemId);
if (item) {
const disabled = this.isItemDisabled(item);
const color = disabled
? this.config.disabledColor
: (resolveColor(item, this.params) ?? "#333333");
const opacity = disabled ? 0.5 : 0.7;
// Update path appearance (skip fill for timer items — gradient handles it)
if (!item.timerFraction) {
path.attr(
"fill",
d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color,
);
}
path.style("opacity", disabled ? 0.5 : 1);
path.style("cursor", disabled ? "not-allowed" : "pointer");
// Update icon/text appearance using the same logic as renderIconsAndText
const icon = this.menuIcons.get(itemId);
if (icon) {
if (item.renderType === "allyExtend" && this.params) {
this.refreshAllyExtendIcon(item, disabled, icon);
} else {
// Update text opacity
const textElement = icon.select("text");
if (!textElement.empty()) {
textElement.style("opacity", disabled ? 0.5 : 1);
}
// Update image opacity
const imageElement = icon.select("image");
if (!imageElement.empty()) {
imageElement.attr("opacity", disabled ? 0.5 : 1);
}
// Update cooldown text if applicable
const cooldownElement = icon.select(".cooldown-text");
if (this.params && !cooldownElement.empty() && item.cooldown) {
const cooldown = Math.ceil(item.cooldown(this.params));
if (cooldown <= 0) {
cooldownElement.remove();
} else {
cooldownElement.text(cooldown + "s");
}
}
}
// Update timer gradient
this.maybeUpdateTimerGradient(item, color, opacity);
}
}
});
// Refresh center button state
this.updateCenterButtonState(this.centerButtonState);
}
private refreshAllyExtendIcon(
item: MenuElement,
disabled: boolean,
icon: d3.Selection<SVGImageElement, unknown, null, undefined>,
): void {
if (item.renderType !== "allyExtend" || !this.params) {
return;
}
const stateKey = this.getStateKeyByType(
item.renderType,
disabled,
this.params,
);
const prevState = icon.attr("data-prev-state");
if (stateKey && stateKey === prevState) {
// State unchanged, skip re-render to preserve animations
} else {
const cx = parseFloat(icon.attr("data-cx") || "0");
const cy = parseFloat(icon.attr("data-cy") || "0");
if (stateKey) {
icon.attr("data-prev-state", stateKey);
} else {
icon.selectAll("*").remove();
}
this.renderAllyExtendIcon(
icon.node()! as SVGGElement,
cx,
cy,
this.config.iconSize,
disabled,
this.params,
item.icon,
true,
);
}
}
private maybeUpdateTimerGradient(
item: MenuElement,
color: string,
opacity: number,
): void {
if (!item.timerFraction || !this.params) {
return;
}
const fraction = item.timerFraction(this.params);
const gradient = this.menuElement.select(`#timer-gradient-${item.id}`);
if (!gradient.empty()) {
const offset = 1 - fraction;
const normalColor =
d3.color(color)?.copy({ opacity: opacity })?.toString() ?? color;
const interpolated = d3.color(d3.interpolateRgb(color, "white")(0.4));
const fadedColor =
interpolated?.copy({ opacity })?.toString() ?? normalColor;
gradient
.select(".timer-stop-faded")
.attr("offset", offset)
.attr("stop-color", fadedColor);
gradient
.select(".timer-stop-normal")
.attr("offset", offset)
.attr("stop-color", normalColor);
}
}
private getStateKeyByType(
type: string,
disabled: boolean,
params: MenuElementParams,
): string | null {
switch (type) {
case "allyExtend":
return this.getAllyExtendStateKey(disabled, params);
default:
return null;
}
}
private getAllyExtendStateKey(
disabled: boolean,
params: MenuElementParams,
): string {
const interaction = params.playerActions?.interaction;
const myAgreed = interaction?.allianceInfo?.myPlayerAgreedToExtend ?? false;
const otherAgreed = interaction?.allianceInfo?.otherAgreedToExtend ?? false;
return `${disabled}:${myAgreed}:${otherAgreed}`;
}
private renderAllyExtendIcon(
content: SVGGElement,
cx: number,
cy: number,
iconSize: number,
disabled: boolean,
params: MenuElementParams,
icon?: string,
update?: boolean,
): void {
if (update) {
while (content.firstChild) content.removeChild(content.firstChild);
}
const interaction = params.playerActions?.interaction;
const myAgreed = interaction?.allianceInfo?.myPlayerAgreedToExtend ?? false;
const otherAgreed = interaction?.allianceInfo?.otherAgreedToExtend ?? false;
const ns = "http://www.w3.org/2000/svg";
const smallSize = iconSize * 0.8;
const iconUrl = icon ?? "";
getSvgAspectRatio(iconUrl).then((ratio) => {
const width = smallSize * (ratio ?? 1);
const gap = 2;
const totalWidth = width * 2 + gap;
// Left handshake = me
const leftImg = document.createElementNS(ns, "image");
leftImg.setAttribute("href", iconUrl);
leftImg.setAttribute("width", width.toString());
leftImg.setAttribute("height", smallSize.toString());
leftImg.setAttribute("x", (cx - totalWidth / 2).toString());
leftImg.setAttribute("y", (cy - smallSize / 2).toString());
leftImg.setAttribute("opacity", disabled ? "0.5" : "1");
if (!myAgreed) {
const animLeft = document.createElementNS(ns, "animate");
animLeft.setAttribute("attributeName", "opacity");
animLeft.setAttribute("values", disabled ? "0.5;0.1;0.5" : "1;0.2;1");
animLeft.setAttribute("dur", "1.5s");
animLeft.setAttribute("repeatCount", "indefinite");
leftImg.appendChild(animLeft);
}
content.appendChild(leftImg);
// Right handshake = them
const rightImg = document.createElementNS(ns, "image");
rightImg.setAttribute("href", iconUrl);
rightImg.setAttribute("width", width.toString());
rightImg.setAttribute("height", smallSize.toString());
rightImg.setAttribute(
"x",
(cx - totalWidth / 2 + width + gap).toString(),
);
rightImg.setAttribute("y", (cy - smallSize / 2).toString());
rightImg.setAttribute("opacity", disabled ? "0.5" : "1");
if (!otherAgreed) {
const animRight = document.createElementNS(ns, "animate");
animRight.setAttribute("attributeName", "opacity");
animRight.setAttribute("values", disabled ? "0.5;0.1;0.5" : "1;0.2;1");
animRight.setAttribute("dur", "1.5s");
animRight.setAttribute("repeatCount", "indefinite");
rightImg.appendChild(animRight);
}
content.appendChild(rightImg);
});
}
renderLayer(context: CanvasRenderingContext2D) {
// No need to render anything on the canvas
}
shouldTransform(): boolean {
return false;
}
private isReopeningAllowed(): boolean {
const now = Date.now();
const timeSinceHide = now - this.lastHideTime;
return timeSinceHide >= this.reopenCooldownMs;
}
private showTooltip(items: TooltipItem[] | TooltipKey[]) {
if (!this.tooltipElement) return;
this.tooltipElement.innerHTML = "";
for (const item of items) {
const div = document.createElement("div");
div.className = item.className;
if ("key" in item) {
div.textContent = translateText(item.key, item.params);
} else {
div.textContent = item.text;
}
this.tooltipElement.appendChild(div);
}
this.tooltipElement.style.display = "block";
}
private hideTooltip() {
if (this.tooltipElement) {
this.tooltipElement.style.display = "none";
}
}
// Ensure the menu's SVG center stays within viewport given the current level's outer radius
private clampAndSetMenuPositionForLevel(level: number) {
const outerRadius = this.getOuterRadiusForLevel(level);
const margin = Math.max(outerRadius, this.config.centerButtonSize) + 10;
const vw = window.innerWidth;
const vh = window.innerHeight;
// If the menu cannot fully fit on an axis, pin it to the viewport center on that axis.
const clampedX =
2 * margin > vw
? vw / 2
: Math.min(Math.max(this.anchorX, margin), vw - margin);
const clampedY =
2 * margin > vh
? vh / 2
: Math.min(Math.max(this.anchorY, margin), vh - margin);
const svgSel = this.menuElement.select("svg");
svgSel.style("top", `${clampedY}px`).style("left", `${clampedX}px`);
}
private handleResize = () => {
if (this.isVisible) this.clampAndSetMenuPositionForLevel(this.currentLevel);
};
}