@axiapps/forge-render 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +33 -0
- package/src/build-helpers.js +81 -0
- package/src/comp-card.js +63 -0
- package/src/escape.js +10 -0
- package/src/forge-render.css +767 -0
- package/src/hover-preview.js +87 -0
- package/src/index.js +12 -0
- package/src/mini-build-card.js +304 -0
- package/src/profession-icons.js +91 -0
- package/src/role-estimator.js +158 -0
- package/src/weapon-icons.js +52 -0
- package/src/weapons.js +28 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Standalone hover preview: a fixed-position card that follows the cursor.
|
|
2
|
+
// Framework-free; the host owns the container element and what HTML to show.
|
|
3
|
+
import { escapeHtml } from "./escape.js";
|
|
4
|
+
|
|
5
|
+
export function positionHoverPreview(node, x, y) {
|
|
6
|
+
if (!node || node.classList.contains("hidden")) return;
|
|
7
|
+
const pad = 8;
|
|
8
|
+
const offset = 16;
|
|
9
|
+
const vw = window.innerWidth;
|
|
10
|
+
const vh = window.innerHeight;
|
|
11
|
+
const rect = node.getBoundingClientRect();
|
|
12
|
+
let left = Number(x) + offset;
|
|
13
|
+
let top = Number(y) + offset;
|
|
14
|
+
if (left + rect.width > vw - pad) left = Number(x) - rect.width - offset;
|
|
15
|
+
if (top + rect.height > vh - pad) top = Number(y) - rect.height - offset;
|
|
16
|
+
left = Math.max(pad, Math.min(left, vw - rect.width - pad));
|
|
17
|
+
top = Math.max(pad, Math.min(top, vh - rect.height - pad));
|
|
18
|
+
node.style.left = `${left}px`;
|
|
19
|
+
node.style.top = `${top}px`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Minimal entity card for catalog records ({ name, icon, description, facts? }). */
|
|
23
|
+
export function renderEntityHoverHtml(entity, meta = "") {
|
|
24
|
+
if (!entity) return "";
|
|
25
|
+
const icon = entity.icon
|
|
26
|
+
? `<img class="hover-preview__icon" src="${escapeHtml(entity.icon)}" alt="" loading="lazy">`
|
|
27
|
+
: "";
|
|
28
|
+
const facts = (entity.facts || [])
|
|
29
|
+
.filter((f) => f && f.text)
|
|
30
|
+
.slice(0, 8)
|
|
31
|
+
.map((f) => `<li>${escapeHtml(f.text)}${f.value !== undefined ? `: ${escapeHtml(String(f.value))}` : ""}</li>`)
|
|
32
|
+
.join("");
|
|
33
|
+
return `
|
|
34
|
+
<div class="hover-preview__head">
|
|
35
|
+
${icon}
|
|
36
|
+
<div>
|
|
37
|
+
<p class="hover-preview__title">${escapeHtml(entity.name || "")}</p>
|
|
38
|
+
${meta ? `<p class="hover-preview__meta">${escapeHtml(meta)}</p>` : ""}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
${entity.description ? `<p class="hover-preview__desc">${escapeHtml(entity.description)}</p>` : ""}
|
|
42
|
+
${facts ? `<ul class="hover-preview__bonuses">${facts}</ul>` : ""}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Creates a hover-preview controller bound to a host element. The card node
|
|
47
|
+
* is appended to `host` (give the host class="forge-render" so the scoped
|
|
48
|
+
* CSS applies). Returns { bind, hide, destroy }.
|
|
49
|
+
*/
|
|
50
|
+
export function createHoverPreview(host) {
|
|
51
|
+
const node = document.createElement("div");
|
|
52
|
+
node.className = "hover-preview hidden";
|
|
53
|
+
host.appendChild(node);
|
|
54
|
+
const unbinders = [];
|
|
55
|
+
|
|
56
|
+
const show = (html, x, y) => {
|
|
57
|
+
node.innerHTML = html;
|
|
58
|
+
node.classList.remove("hidden");
|
|
59
|
+
positionHoverPreview(node, x, y);
|
|
60
|
+
};
|
|
61
|
+
const hide = () => node.classList.add("hidden");
|
|
62
|
+
|
|
63
|
+
const bind = (target, htmlProvider) => {
|
|
64
|
+
const read = () => (typeof htmlProvider === "function" ? htmlProvider() : htmlProvider || "");
|
|
65
|
+
const onEnter = (event) => {
|
|
66
|
+
const html = read();
|
|
67
|
+
if (html) show(html, event.clientX, event.clientY);
|
|
68
|
+
};
|
|
69
|
+
const onMove = (event) => positionHoverPreview(node, event.clientX, event.clientY);
|
|
70
|
+
const onLeave = () => hide();
|
|
71
|
+
target.addEventListener("mouseenter", onEnter);
|
|
72
|
+
target.addEventListener("mousemove", onMove);
|
|
73
|
+
target.addEventListener("mouseleave", onLeave);
|
|
74
|
+
unbinders.push(() => {
|
|
75
|
+
target.removeEventListener("mouseenter", onEnter);
|
|
76
|
+
target.removeEventListener("mousemove", onMove);
|
|
77
|
+
target.removeEventListener("mouseleave", onLeave);
|
|
78
|
+
});
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const destroy = () => {
|
|
82
|
+
for (const un of unbinders) un();
|
|
83
|
+
node.remove();
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
return { bind, hide, destroy };
|
|
87
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { renderMiniBuildCard, renderMissingMiniBuildCard } from "./mini-build-card.js";
|
|
2
|
+
export { renderCompCard } from "./comp-card.js";
|
|
3
|
+
export { createHoverPreview, positionHoverPreview, renderEntityHoverHtml } from "./hover-preview.js";
|
|
4
|
+
export { estimateRole, roleBadgeHtml } from "./role-estimator.js";
|
|
5
|
+
export {
|
|
6
|
+
getEliteSpecName, getSpecIcon, getSpecIconColored,
|
|
7
|
+
profClass, getDisplayName, resolveStatPackage, getRuneName,
|
|
8
|
+
} from "./build-helpers.js";
|
|
9
|
+
export { getProfessionSvg, getProfessionSvgColored } from "./profession-icons.js";
|
|
10
|
+
export { getWeaponSvg } from "./weapon-icons.js";
|
|
11
|
+
export { GW2_WEAPONS, GW2_WEAPONS_BY_ID } from "./weapons.js";
|
|
12
|
+
export { escapeHtml } from "./escape.js";
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// Mini Build Card — reusable compact build summary card.
|
|
2
|
+
|
|
3
|
+
import { escapeHtml } from "./escape.js";
|
|
4
|
+
import { GW2_WEAPONS_BY_ID } from "./weapons.js";
|
|
5
|
+
import { getProfessionSvg, getProfessionSvgColored } from "./profession-icons.js";
|
|
6
|
+
import { getWeaponSvg } from "./weapon-icons.js";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
getSpecIcon,
|
|
10
|
+
getSpecIconColored,
|
|
11
|
+
profClass,
|
|
12
|
+
getDisplayName,
|
|
13
|
+
resolveStatPackage,
|
|
14
|
+
getRuneName,
|
|
15
|
+
} from "./build-helpers.js";
|
|
16
|
+
import { roleBadgeHtml } from "./role-estimator.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Return array of { name, isElite, svg } for each specialization on the build.
|
|
20
|
+
* Elite specs get their own SVG icon; core specs get the profession icon as fallback.
|
|
21
|
+
*/
|
|
22
|
+
function getSpecLineInfo(build, color) {
|
|
23
|
+
if (!build.specializations) return [];
|
|
24
|
+
const getSvg = (name) => color && color !== "normal"
|
|
25
|
+
? (getProfessionSvgColored(name, color) || "")
|
|
26
|
+
: (getProfessionSvg(name) || "");
|
|
27
|
+
const profSvg = getSvg(build.profession);
|
|
28
|
+
return build.specializations
|
|
29
|
+
.filter((s) => s && s.name)
|
|
30
|
+
.map((s) => ({
|
|
31
|
+
name: s.name,
|
|
32
|
+
isElite: !!s.elite,
|
|
33
|
+
svg: getSvg(s.name) || profSvg,
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolve the relic icon URL from the live wiki-synced catalog.
|
|
39
|
+
*/
|
|
40
|
+
function getRelicIcon(relicName, upgradeCatalog) {
|
|
41
|
+
if (!relicName) return null;
|
|
42
|
+
return upgradeCatalog?.relicByName?.get(relicName)?.icon || null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Resolve the rune icon URL from upgradeCatalog, or null.
|
|
47
|
+
*/
|
|
48
|
+
function getRuneIcon(build, upgradeCatalog) {
|
|
49
|
+
const runes = build.equipment?.runes;
|
|
50
|
+
if (!runes || typeof runes !== "object" || !upgradeCatalog) return null;
|
|
51
|
+
const counts = {};
|
|
52
|
+
for (const v of Object.values(runes)) {
|
|
53
|
+
if (v) counts[String(v)] = (counts[String(v)] || 0) + 1;
|
|
54
|
+
}
|
|
55
|
+
let bestId = "";
|
|
56
|
+
let bestCount = 0;
|
|
57
|
+
for (const [id, count] of Object.entries(counts)) {
|
|
58
|
+
if (count > bestCount) { bestId = id; bestCount = count; }
|
|
59
|
+
}
|
|
60
|
+
if (!bestId) return null;
|
|
61
|
+
const runeDef = upgradeCatalog.runeById?.get(Number(bestId));
|
|
62
|
+
return runeDef?.icon || null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Return array of weapon set objects: [{ weapons: [{ id, label }], display }].
|
|
67
|
+
* Each set has the individual weapons (for icons) and a display string.
|
|
68
|
+
*/
|
|
69
|
+
function getWeaponSets(build) {
|
|
70
|
+
const weaps = build.equipment?.weapons;
|
|
71
|
+
if (!weaps) return [];
|
|
72
|
+
|
|
73
|
+
const resolve = (id) => {
|
|
74
|
+
if (!id) return null;
|
|
75
|
+
const w = GW2_WEAPONS_BY_ID.get(id);
|
|
76
|
+
return w ? { id, label: w.label } : { id, label: id };
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const sets = [];
|
|
80
|
+
|
|
81
|
+
// Set 1
|
|
82
|
+
const mh1 = resolve(weaps.mainhand1);
|
|
83
|
+
const oh1 = resolve(weaps.offhand1);
|
|
84
|
+
if (mh1 || oh1) {
|
|
85
|
+
const weapons = [mh1, oh1].filter(Boolean);
|
|
86
|
+
sets.push({ weapons, display: weapons.map((w) => w.label).join(" / ") });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Set 2
|
|
90
|
+
const mh2 = resolve(weaps.mainhand2);
|
|
91
|
+
const oh2 = resolve(weaps.offhand2);
|
|
92
|
+
if (mh2 || oh2) {
|
|
93
|
+
const weapons = [mh2, oh2].filter(Boolean);
|
|
94
|
+
sets.push({ weapons, display: weapons.map((w) => w.label).join(" / ") });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return sets;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Render a mini build card as an HTML string.
|
|
102
|
+
*
|
|
103
|
+
* @param {Object} build - Build object from state
|
|
104
|
+
* @param {Object} upgradeCatalog - state.upgradeCatalog (for rune name resolution)
|
|
105
|
+
* @param {Object} [options]
|
|
106
|
+
* @param {boolean} [options.showActions=true] - Show open/remove buttons
|
|
107
|
+
* @param {boolean} [options.showMode=true] - Show game mode pill
|
|
108
|
+
* @returns {string} HTML string
|
|
109
|
+
*/
|
|
110
|
+
export function renderMiniBuildCard(build, upgradeCatalog, options = {}) {
|
|
111
|
+
const { showActions = true, showMode = true, linkUrl = null, chatLink = null, linkBadge = null, slotColor = null, showColorPicker = false } = options;
|
|
112
|
+
|
|
113
|
+
const icon = slotColor && slotColor !== "normal" ? getSpecIconColored(build, slotColor) : getSpecIcon(build);
|
|
114
|
+
const pClass = profClass(build.profession);
|
|
115
|
+
const name = escapeHtml(getDisplayName(build));
|
|
116
|
+
const gameMode = build.gameMode || "pve";
|
|
117
|
+
|
|
118
|
+
// Tag pills
|
|
119
|
+
const tagPills = (build.tags || [])
|
|
120
|
+
.map((t) => `<span class="mini-card__tag">${escapeHtml(t)}</span>`)
|
|
121
|
+
.join("");
|
|
122
|
+
|
|
123
|
+
// Role badge
|
|
124
|
+
const role = roleBadgeHtml(build, upgradeCatalog);
|
|
125
|
+
|
|
126
|
+
// Mode pill
|
|
127
|
+
const modePill = showMode
|
|
128
|
+
? `<span class="mini-card__mode">${escapeHtml(gameMode)}</span>`
|
|
129
|
+
: "";
|
|
130
|
+
|
|
131
|
+
// Left column: Specs as mini cards
|
|
132
|
+
const specs = getSpecLineInfo(build, slotColor);
|
|
133
|
+
let specColHtml = "";
|
|
134
|
+
if (specs.length) {
|
|
135
|
+
const specLines = specs.map((s) => {
|
|
136
|
+
const pipClass = s.isElite ? "mini-card__spec-pip mini-card__spec-pip--elite" : "mini-card__spec-pip";
|
|
137
|
+
const nameClass = s.isElite ? "mini-card__spec-name mini-card__spec-name--elite" : "mini-card__spec-name";
|
|
138
|
+
const pipContent = s.svg || escapeHtml(s.name.charAt(0).toUpperCase());
|
|
139
|
+
return `<div class="mini-card__spec-line"><span class="${pipClass}">${pipContent}</span><span class="${nameClass}">${escapeHtml(s.name)}</span></div>`;
|
|
140
|
+
}).join("");
|
|
141
|
+
|
|
142
|
+
specColHtml = `
|
|
143
|
+
<div class="mini-card__col-left">
|
|
144
|
+
<span class="mini-card__detail-label">Specs</span>
|
|
145
|
+
<div class="mini-card__spec-list">${specLines}</div>
|
|
146
|
+
</div>`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Right column: Weapons, Gear, Relic (stacked)
|
|
150
|
+
const weaponSets = getWeaponSets(build);
|
|
151
|
+
let weapRowHtml = "";
|
|
152
|
+
if (weaponSets.length) {
|
|
153
|
+
const weapHtml = weaponSets.map((set) => {
|
|
154
|
+
return set.weapons.map((w) => {
|
|
155
|
+
const svg = getWeaponSvg(w.id);
|
|
156
|
+
const icon = svg ? `<span class="mini-card__weap-icon">${svg}</span>` : "";
|
|
157
|
+
return `${icon}<span class="mini-card__weap-name">${escapeHtml(w.label)}</span>`;
|
|
158
|
+
}).join(`<span class="mini-card__weap-sep">/</span>`);
|
|
159
|
+
}).join(`<span class="mini-card__weap-div">|</span>`);
|
|
160
|
+
|
|
161
|
+
weapRowHtml = `
|
|
162
|
+
<div class="mini-card__cell">
|
|
163
|
+
<span class="mini-card__detail-label">Weap</span>
|
|
164
|
+
<div class="mini-card__weap-group">${weapHtml}</div>
|
|
165
|
+
</div>`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const statPackage = resolveStatPackage(build);
|
|
169
|
+
let statRowHtml = "";
|
|
170
|
+
if (statPackage) {
|
|
171
|
+
statRowHtml = `
|
|
172
|
+
<div class="mini-card__cell">
|
|
173
|
+
<span class="mini-card__detail-label">Stats</span>
|
|
174
|
+
<span class="mini-card__gear-icon mini-card__gear-icon--stat">◆</span>
|
|
175
|
+
<span class="mini-card__stat">${escapeHtml(statPackage)}</span>
|
|
176
|
+
</div>`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const runeName = getRuneName(build, upgradeCatalog);
|
|
180
|
+
const runeIconUrl = getRuneIcon(build, upgradeCatalog);
|
|
181
|
+
let runeRowHtml = "";
|
|
182
|
+
if (runeName) {
|
|
183
|
+
const runeIcon = runeIconUrl
|
|
184
|
+
? `<img class="mini-card__gear-img" src="${escapeHtml(runeIconUrl)}" alt="" loading="lazy">`
|
|
185
|
+
: `<span class="mini-card__gear-icon mini-card__gear-icon--rune">ᚱ</span>`;
|
|
186
|
+
runeRowHtml = `
|
|
187
|
+
<div class="mini-card__cell">
|
|
188
|
+
<span class="mini-card__detail-label">Rune</span>
|
|
189
|
+
${runeIcon}
|
|
190
|
+
<span class="mini-card__equip">${escapeHtml(runeName)}</span>
|
|
191
|
+
</div>`;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const relicName = build.equipment?.relic || "";
|
|
195
|
+
const relicIconUrl = getRelicIcon(relicName, upgradeCatalog);
|
|
196
|
+
let relicRowHtml = "";
|
|
197
|
+
if (relicName) {
|
|
198
|
+
const relicIcon = relicIconUrl
|
|
199
|
+
? `<img class="mini-card__gear-img" src="${escapeHtml(relicIconUrl)}" alt="" loading="lazy">`
|
|
200
|
+
: `<span class="mini-card__gear-icon mini-card__gear-icon--relic">⬡</span>`;
|
|
201
|
+
relicRowHtml = `
|
|
202
|
+
<div class="mini-card__cell">
|
|
203
|
+
<span class="mini-card__detail-label">Relic</span>
|
|
204
|
+
${relicIcon}
|
|
205
|
+
<span class="mini-card__relic">${escapeHtml(relicName)}</span>
|
|
206
|
+
</div>`;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const rightColHtml = (weapRowHtml || statRowHtml || runeRowHtml || relicRowHtml)
|
|
210
|
+
? `<div class="mini-card__col-right">${weapRowHtml}${statRowHtml}${runeRowHtml}${relicRowHtml}</div>`
|
|
211
|
+
: "";
|
|
212
|
+
|
|
213
|
+
// Title is a clickable link to open the build
|
|
214
|
+
const titleHtml = showActions
|
|
215
|
+
? `<button type="button" class="mini-card__name" data-action="pool-open"
|
|
216
|
+
data-build-id="${escapeHtml(build.id)}" title="Open build">${name} <span class="mini-card__name-arrow">↗</span></button>`
|
|
217
|
+
: linkUrl
|
|
218
|
+
? `<a href="${escapeHtml(linkUrl)}" target="_blank" rel="noopener" class="mini-card__name" title="Open build">${name} <span class="mini-card__name-arrow">↗</span></a>`
|
|
219
|
+
: `<span class="mini-card__name">${name}</span>`;
|
|
220
|
+
|
|
221
|
+
// Link badge — shows this build is a reference, not owned by the comp
|
|
222
|
+
const linkBadgeHtml = linkBadge
|
|
223
|
+
? `<span class="mini-card__link-badge" title="${escapeHtml(linkBadge.tooltip)}">
|
|
224
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>
|
|
225
|
+
${escapeHtml(linkBadge.label)}
|
|
226
|
+
</span>`
|
|
227
|
+
: "";
|
|
228
|
+
|
|
229
|
+
// Remove button — absolute top-right
|
|
230
|
+
const removeHtml = showActions
|
|
231
|
+
? `<button type="button" class="mini-card__btn-remove" data-action="pool-remove"
|
|
232
|
+
data-build-id="${escapeHtml(build.id)}" title="Unlink from comp">×</button>`
|
|
233
|
+
: "";
|
|
234
|
+
|
|
235
|
+
// Copy build code button (SPA comp view)
|
|
236
|
+
const copyCodeHtml = chatLink
|
|
237
|
+
? `<button type="button" class="mini-card__btn-copy-code" data-chat-link="${escapeHtml(chatLink)}" title="Copy build code">
|
|
238
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
|
239
|
+
<span>Code</span>
|
|
240
|
+
</button>`
|
|
241
|
+
: "";
|
|
242
|
+
|
|
243
|
+
// Color picker dropdown (Condi / Heal)
|
|
244
|
+
const colorPickerHtml = showColorPicker ? (() => {
|
|
245
|
+
const c = slotColor || "normal";
|
|
246
|
+
const dotColor = c === "red" ? "#d63a3a" : c === "blue" ? "#3a8fd6" : "transparent";
|
|
247
|
+
const dotStyle = c === "normal"
|
|
248
|
+
? 'style="border:1px solid #666;background:transparent"'
|
|
249
|
+
: `style="background:${dotColor}"`;
|
|
250
|
+
return `<div class="mini-card__color-picker" data-action="color-picker" data-build-id="${escapeHtml(build.id)}" data-current="${c}" title="Icon color">
|
|
251
|
+
<span class="mini-card__color-dot" ${dotStyle}></span>
|
|
252
|
+
<span class="mini-card__color-label">${c === "red" ? "Condi" : c === "blue" ? "Heal" : "Default"}</span>
|
|
253
|
+
<span class="mini-card__color-caret">▾</span>
|
|
254
|
+
</div>`;
|
|
255
|
+
})() : "";
|
|
256
|
+
|
|
257
|
+
return `
|
|
258
|
+
<div class="mini-card ${pClass}" data-build-id="${escapeHtml(build.id)}"${slotColor && slotColor !== "normal" ? ` data-slot-color="${slotColor}"` : ""}>
|
|
259
|
+
${removeHtml}
|
|
260
|
+
<div class="mini-card__icon">${icon}</div>
|
|
261
|
+
<div class="mini-card__info">
|
|
262
|
+
<div class="mini-card__header">
|
|
263
|
+
${titleHtml}
|
|
264
|
+
${linkBadgeHtml}
|
|
265
|
+
${tagPills}
|
|
266
|
+
${role}
|
|
267
|
+
<div class="mini-card__pills">
|
|
268
|
+
${colorPickerHtml}
|
|
269
|
+
${copyCodeHtml}
|
|
270
|
+
${modePill}
|
|
271
|
+
</div>
|
|
272
|
+
</div>
|
|
273
|
+
<div class="mini-card__columns">
|
|
274
|
+
${specColHtml}
|
|
275
|
+
${rightColHtml}
|
|
276
|
+
</div>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
`;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Render a placeholder card for a build that no longer exists in the library.
|
|
284
|
+
*/
|
|
285
|
+
export function renderMissingMiniBuildCard(buildId) {
|
|
286
|
+
const truncId = buildId.length > 12 ? buildId.slice(0, 12) + "\u2026" : buildId;
|
|
287
|
+
return `
|
|
288
|
+
<div class="mini-card mini-card--missing" data-build-id="${escapeHtml(buildId)}">
|
|
289
|
+
<div class="mini-card__icon mini-card__icon--missing">?</div>
|
|
290
|
+
<div class="mini-card__info">
|
|
291
|
+
<div class="mini-card__header">
|
|
292
|
+
<span class="mini-card__name mini-card__name--missing">Missing Build</span>
|
|
293
|
+
</div>
|
|
294
|
+
<div class="mini-card__detail-row">
|
|
295
|
+
<span class="mini-card__equip">${escapeHtml(truncId)}</span>
|
|
296
|
+
</div>
|
|
297
|
+
</div>
|
|
298
|
+
<div class="mini-card__actions">
|
|
299
|
+
<button type="button" class="mini-card__btn-remove" data-action="pool-remove"
|
|
300
|
+
data-build-id="${escapeHtml(buildId)}" title="Remove from comp">×</button>
|
|
301
|
+
</div>
|
|
302
|
+
</div>
|
|
303
|
+
`;
|
|
304
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Profession and elite-spec class icons from gw2-class-icons package.
|
|
2
|
+
// Imported as raw SVG strings via Vite's ?raw suffix.
|
|
3
|
+
// The package's index.js is Node-only (uses fs.readdirSync) — we bypass it entirely.
|
|
4
|
+
import Amalgam from "gw2-class-icons/wiki/svg/Amalgam.svg?raw";
|
|
5
|
+
import Antiquary from "gw2-class-icons/wiki/svg/Antiquary.svg?raw";
|
|
6
|
+
import Berserker from "gw2-class-icons/wiki/svg/Berserker.svg?raw";
|
|
7
|
+
import Bladesworn from "gw2-class-icons/wiki/svg/Bladesworn.svg?raw";
|
|
8
|
+
import Catalyst from "gw2-class-icons/wiki/svg/Catalyst.svg?raw";
|
|
9
|
+
import Chronomancer from "gw2-class-icons/wiki/svg/Chronomancer.svg?raw";
|
|
10
|
+
import Conduit from "gw2-class-icons/wiki/svg/Conduit.svg?raw";
|
|
11
|
+
import Daredevil from "gw2-class-icons/wiki/svg/Daredevil.svg?raw";
|
|
12
|
+
import Deadeye from "gw2-class-icons/wiki/svg/Deadeye.svg?raw";
|
|
13
|
+
import Dragonhunter from "gw2-class-icons/wiki/svg/Dragonhunter.svg?raw";
|
|
14
|
+
import Druid from "gw2-class-icons/wiki/svg/Druid.svg?raw";
|
|
15
|
+
import Elementalist from "gw2-class-icons/wiki/svg/Elementalist.svg?raw";
|
|
16
|
+
import Engineer from "gw2-class-icons/wiki/svg/Engineer.svg?raw";
|
|
17
|
+
import Evoker from "gw2-class-icons/wiki/svg/Evoker.svg?raw";
|
|
18
|
+
import Firebrand from "gw2-class-icons/wiki/svg/Firebrand.svg?raw";
|
|
19
|
+
import Galeshot from "gw2-class-icons/wiki/svg/Galeshot.svg?raw";
|
|
20
|
+
import Guardian from "gw2-class-icons/wiki/svg/Guardian.svg?raw";
|
|
21
|
+
import Harbinger from "gw2-class-icons/wiki/svg/Harbinger.svg?raw";
|
|
22
|
+
import Herald from "gw2-class-icons/wiki/svg/Herald.svg?raw";
|
|
23
|
+
import Holosmith from "gw2-class-icons/wiki/svg/Holosmith.svg?raw";
|
|
24
|
+
import Luminary from "gw2-class-icons/wiki/svg/Luminary.svg?raw";
|
|
25
|
+
import Mechanist from "gw2-class-icons/wiki/svg/Mechanist.svg?raw";
|
|
26
|
+
import Mesmer from "gw2-class-icons/wiki/svg/Mesmer.svg?raw";
|
|
27
|
+
import Mirage from "gw2-class-icons/wiki/svg/Mirage.svg?raw";
|
|
28
|
+
import Necromancer from "gw2-class-icons/wiki/svg/Necromancer.svg?raw";
|
|
29
|
+
import Paragon from "gw2-class-icons/wiki/svg/Paragon.svg?raw";
|
|
30
|
+
import Ranger from "gw2-class-icons/wiki/svg/Ranger.svg?raw";
|
|
31
|
+
import Reaper from "gw2-class-icons/wiki/svg/Reaper.svg?raw";
|
|
32
|
+
import Renegade from "gw2-class-icons/wiki/svg/Renegade.svg?raw";
|
|
33
|
+
import Revenant from "gw2-class-icons/wiki/svg/Revenant.svg?raw";
|
|
34
|
+
import Ritualist from "gw2-class-icons/wiki/svg/Ritualist.svg?raw";
|
|
35
|
+
import Scourge from "gw2-class-icons/wiki/svg/Scourge.svg?raw";
|
|
36
|
+
import Scrapper from "gw2-class-icons/wiki/svg/Scrapper.svg?raw";
|
|
37
|
+
import Soulbeast from "gw2-class-icons/wiki/svg/Soulbeast.svg?raw";
|
|
38
|
+
import Specter from "gw2-class-icons/wiki/svg/Specter.svg?raw";
|
|
39
|
+
import Spellbreaker from "gw2-class-icons/wiki/svg/Spellbreaker.svg?raw";
|
|
40
|
+
import Tempest from "gw2-class-icons/wiki/svg/Tempest.svg?raw";
|
|
41
|
+
import Thief from "gw2-class-icons/wiki/svg/Thief.svg?raw";
|
|
42
|
+
import Troubadour from "gw2-class-icons/wiki/svg/Troubadour.svg?raw";
|
|
43
|
+
import Untamed from "gw2-class-icons/wiki/svg/Untamed.svg?raw";
|
|
44
|
+
import Vindicator from "gw2-class-icons/wiki/svg/Vindicator.svg?raw";
|
|
45
|
+
import Virtuoso from "gw2-class-icons/wiki/svg/Virtuoso.svg?raw";
|
|
46
|
+
import Warrior from "gw2-class-icons/wiki/svg/Warrior.svg?raw";
|
|
47
|
+
import Weaver from "gw2-class-icons/wiki/svg/Weaver.svg?raw";
|
|
48
|
+
import Willbender from "gw2-class-icons/wiki/svg/Willbender.svg?raw";
|
|
49
|
+
|
|
50
|
+
const SVG_MAP = {
|
|
51
|
+
Amalgam, Antiquary, Berserker, Bladesworn, Catalyst, Chronomancer, Conduit,
|
|
52
|
+
Daredevil, Deadeye, Dragonhunter, Druid, Elementalist, Engineer, Evoker,
|
|
53
|
+
Firebrand, Galeshot, Guardian, Harbinger, Herald, Holosmith, Luminary,
|
|
54
|
+
Mechanist, Mesmer, Mirage, Necromancer, Paragon, Ranger, Reaper, Renegade,
|
|
55
|
+
Revenant, Ritualist, Scourge, Scrapper, Soulbeast, Specter, Spellbreaker,
|
|
56
|
+
Tempest, Thief, Troubadour, Untamed, Vindicator, Virtuoso, Warrior, Weaver,
|
|
57
|
+
Willbender,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Returns the raw SVG string for a profession or elite spec name, or null if unknown.
|
|
62
|
+
* @param {string} name — e.g. "Guardian", "Dragonhunter", "Elementalist"
|
|
63
|
+
*/
|
|
64
|
+
export function getProfessionSvg(name) {
|
|
65
|
+
return SVG_MAP[name] ?? null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const LINE_COLORS = {
|
|
69
|
+
red: "#d63a3a",
|
|
70
|
+
blue: "#3a8fd6",
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const _colorCache = new Map();
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Returns the raw SVG with non-black fills replaced by the given line color.
|
|
77
|
+
* "normal" (or falsy) returns the original SVG unchanged.
|
|
78
|
+
*/
|
|
79
|
+
export function getProfessionSvgColored(name, color) {
|
|
80
|
+
const svg = SVG_MAP[name] ?? null;
|
|
81
|
+
if (!svg || !color || color === "normal") return svg;
|
|
82
|
+
const hex = LINE_COLORS[color];
|
|
83
|
+
if (!hex) return svg;
|
|
84
|
+
const key = `${name}_${color}`;
|
|
85
|
+
let cached = _colorCache.get(key);
|
|
86
|
+
if (!cached) {
|
|
87
|
+
cached = svg.replace(/fill:#(?!000000)[0-9a-fA-F]{6}/gi, `fill:${hex}`);
|
|
88
|
+
_colorCache.set(key, cached);
|
|
89
|
+
}
|
|
90
|
+
return cached;
|
|
91
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Role estimation from equipment stats — pure functions, no app state.
|
|
2
|
+
// Slot stat math comes from the shared engine; we feed it the build's own
|
|
3
|
+
// weapon set and game mode (the desktop app previously read editor state,
|
|
4
|
+
// which is wrong for library/chat cards anyway).
|
|
5
|
+
import * as _engine from "@axiapps/gw2-data/engine";
|
|
6
|
+
const engine = _engine.default || _engine;
|
|
7
|
+
const { computeSlotStats } = engine;
|
|
8
|
+
|
|
9
|
+
// 700 (not the spec's 1500): tuned down so Celestial gear (~810 max) and partial builds
|
|
10
|
+
// still get a role rather than Unknown. Rune bonuses further lift scores for full builds.
|
|
11
|
+
const MIN_THRESHOLD = 700;
|
|
12
|
+
const HYBRID_RATIO = 0.10;
|
|
13
|
+
|
|
14
|
+
// Tank is intentionally excluded — not a meaningful role category in GW2.
|
|
15
|
+
const ROLE_SCORERS = [
|
|
16
|
+
{ role: 'Power DPS', fn: s => s.Power * 1.0 + s.Precision * 0.5 + s.Ferocity * 0.5 },
|
|
17
|
+
{ role: 'Condi DPS', fn: s => s.ConditionDamage * 1.0 + s.Expertise * 0.8 },
|
|
18
|
+
{ role: 'Boon Support', fn: s => s.Concentration * 1.5 + s.HealingPower * 0.3 },
|
|
19
|
+
{ role: 'Heal Support', fn: s => s.HealingPower * 1.5 + s.Concentration * 0.3 },
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const ROLE_CSS_CLASS = {
|
|
23
|
+
'Power DPS': 'power-dps',
|
|
24
|
+
'Condi DPS': 'condi-dps',
|
|
25
|
+
'Boon Support': 'boon-support',
|
|
26
|
+
'Heal Support': 'heal-support',
|
|
27
|
+
'Hybrid': 'hybrid',
|
|
28
|
+
'Unknown': 'unknown',
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const RUNE_STAT_RE = /\+(\d+)\s+(Condition Damage|Healing Power|Healing|Power|Precision|Toughness|Vitality|Ferocity|Concentration|Expertise|to All Stats)/;
|
|
32
|
+
const RUNE_BOON_RE = /\+(\d+)%\s+Boon Duration/i;
|
|
33
|
+
const RUNE_CONDI_RE = /\+(\d+)%\s+Condition Duration/i;
|
|
34
|
+
|
|
35
|
+
const RUNE_STAT_MAP = {
|
|
36
|
+
'Condition Damage': 'ConditionDamage',
|
|
37
|
+
'Healing Power': 'HealingPower',
|
|
38
|
+
'Healing': 'HealingPower',
|
|
39
|
+
'Power': 'Power',
|
|
40
|
+
'Precision': 'Precision',
|
|
41
|
+
'Toughness': 'Toughness',
|
|
42
|
+
'Vitality': 'Vitality',
|
|
43
|
+
'Ferocity': 'Ferocity',
|
|
44
|
+
'Concentration': 'Concentration',
|
|
45
|
+
'Expertise': 'Expertise',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Returns a stat totals object from the equipped rune set, or null if no runes.
|
|
49
|
+
// catalog = state.upgradeCatalog passed in from the caller.
|
|
50
|
+
function scoreRuneStats(build, catalog) {
|
|
51
|
+
const runeSlots = build?.equipment?.runes;
|
|
52
|
+
if (!runeSlots || !catalog?.runeById) return null;
|
|
53
|
+
|
|
54
|
+
// Count equipped rune IDs (ignore breather slot, only armor slots contribute)
|
|
55
|
+
const counts = {};
|
|
56
|
+
for (const [slot, id] of Object.entries(runeSlots)) {
|
|
57
|
+
if (!id || slot === 'breather') continue;
|
|
58
|
+
const key = Number(id);
|
|
59
|
+
counts[key] = (counts[key] || 0) + 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Find dominant rune
|
|
63
|
+
let bestId = null, bestCount = 0;
|
|
64
|
+
for (const [id, count] of Object.entries(counts)) {
|
|
65
|
+
if (count > bestCount) { bestId = Number(id); bestCount = count; }
|
|
66
|
+
}
|
|
67
|
+
if (!bestId) return null;
|
|
68
|
+
|
|
69
|
+
const runeDef = catalog.runeById.get(bestId);
|
|
70
|
+
if (!runeDef?.bonuses?.length) return null;
|
|
71
|
+
|
|
72
|
+
const totals = {
|
|
73
|
+
Power: 0, Precision: 0, Toughness: 0, Vitality: 0,
|
|
74
|
+
Ferocity: 0, ConditionDamage: 0, Expertise: 0, Concentration: 0, HealingPower: 0,
|
|
75
|
+
};
|
|
76
|
+
const activeBonuses = runeDef.bonuses.slice(0, Math.min(bestCount, 6));
|
|
77
|
+
|
|
78
|
+
for (const bonus of activeBonuses) {
|
|
79
|
+
const statMatch = RUNE_STAT_RE.exec(bonus);
|
|
80
|
+
if (statMatch) {
|
|
81
|
+
const value = parseInt(statMatch[1], 10);
|
|
82
|
+
const rawName = statMatch[2];
|
|
83
|
+
if (rawName === 'to All Stats') {
|
|
84
|
+
for (const k of Object.keys(totals)) totals[k] += value;
|
|
85
|
+
} else {
|
|
86
|
+
const statKey = RUNE_STAT_MAP[rawName];
|
|
87
|
+
if (statKey) totals[statKey] += value;
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const boonMatch = RUNE_BOON_RE.exec(bonus);
|
|
92
|
+
if (boonMatch) { totals.Concentration += parseInt(boonMatch[1], 10) * 15; continue; }
|
|
93
|
+
const condiMatch = RUNE_CONDI_RE.exec(bonus);
|
|
94
|
+
if (condiMatch) { totals.Expertise += parseInt(condiMatch[1], 10) * 15; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return totals;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Note: computeSlotStats returns only the equipment contribution for a slot,
|
|
101
|
+
// not GW2 base stats (Power/Precision/Toughness/Vitality base = 1000 each).
|
|
102
|
+
// Base stats are added separately in computeEquipmentStats() and are NOT
|
|
103
|
+
// present here, so no subtraction is needed before scoring.
|
|
104
|
+
// The returned totals serve as the base for rune stat scoring when a catalog is provided.
|
|
105
|
+
function scoreEquipmentSlots(build) {
|
|
106
|
+
const slots = build?.equipment?.slots || {};
|
|
107
|
+
const weapons = build?.equipment?.weapons || {};
|
|
108
|
+
const gameMode = build?.gameMode || "pve";
|
|
109
|
+
const totals = {
|
|
110
|
+
Power: 0, Precision: 0, Toughness: 0, Vitality: 0,
|
|
111
|
+
Ferocity: 0, ConditionDamage: 0, Expertise: 0, Concentration: 0, HealingPower: 0,
|
|
112
|
+
};
|
|
113
|
+
for (const [slotKey, label] of Object.entries(slots)) {
|
|
114
|
+
if (!label) continue;
|
|
115
|
+
for (const { stat, value } of computeSlotStats(label, slotKey, weapons, gameMode)) {
|
|
116
|
+
if (stat in totals) totals[stat] += value;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return totals;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Returns the estimated role for a build, or null if no slots are equipped.
|
|
124
|
+
* Pure function — reads only from the build object, no global state.
|
|
125
|
+
*/
|
|
126
|
+
export function estimateRole(build, catalog = null) {
|
|
127
|
+
const slots = build?.equipment?.slots;
|
|
128
|
+
if (!slots || !Object.values(slots).some(Boolean)) return null;
|
|
129
|
+
|
|
130
|
+
const equipStats = scoreEquipmentSlots(build);
|
|
131
|
+
const runeStats = catalog ? scoreRuneStats(build, catalog) : null;
|
|
132
|
+
const s = runeStats
|
|
133
|
+
? Object.fromEntries(Object.keys(equipStats).map(k => [k, equipStats[k] + (runeStats[k] || 0)]))
|
|
134
|
+
: equipStats;
|
|
135
|
+
const scored = ROLE_SCORERS.map(({ role, fn }) => ({ role, score: fn(s) }));
|
|
136
|
+
scored.sort((a, b) => b.score - a.score);
|
|
137
|
+
|
|
138
|
+
const [first, second] = scored;
|
|
139
|
+
if (first.score < MIN_THRESHOLD) return 'Unknown';
|
|
140
|
+
if (
|
|
141
|
+
second &&
|
|
142
|
+
second.score >= MIN_THRESHOLD &&
|
|
143
|
+
(first.score - second.score) / first.score < HYBRID_RATIO
|
|
144
|
+
) {
|
|
145
|
+
return 'Hybrid';
|
|
146
|
+
}
|
|
147
|
+
return first.role;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Returns an HTML string for the role badge, or '' if the build has no equipment.
|
|
152
|
+
*/
|
|
153
|
+
export function roleBadgeHtml(build, catalog = null) {
|
|
154
|
+
const role = estimateRole(build, catalog);
|
|
155
|
+
if (!role) return '';
|
|
156
|
+
const cls = ROLE_CSS_CLASS[role] ?? 'unknown';
|
|
157
|
+
return `<span class="role-badge role-badge--${cls}">${role}</span>`;
|
|
158
|
+
}
|