mirror of
https://github.com/Cametendo/devil-fruit-encyclopedia.git
synced 2026-09-13 15:59:48 +02:00
Compare commits
5 Commits
ee140dbe4d
...
codex/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47d40c0cc6 | ||
|
|
c76119b923 | ||
|
|
266925ff4a | ||
|
|
29a2243690 | ||
|
|
c645ee09d2 |
@@ -34,6 +34,7 @@
|
|||||||
|
|
||||||
<script src="js/characters-data.js"></script>
|
<script src="js/characters-data.js"></script>
|
||||||
<script src="js/fruits-data.js"></script>
|
<script src="js/fruits-data.js"></script>
|
||||||
|
<script src="js/portraits.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
<script src="js/character.js"></script>
|
<script src="js/character.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -367,18 +367,18 @@ p { margin: 0 0 1em; color: var(--text-muted); }
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.portrait-svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.char-card__avatar {
|
.char-card__avatar {
|
||||||
width: 84px;
|
width: 84px;
|
||||||
height: 84px;
|
height: 84px;
|
||||||
margin: 0 auto 0.5rem;
|
margin: 0 auto 0.5rem;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
overflow: hidden;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 2.1rem;
|
|
||||||
color: #fff;
|
|
||||||
background: linear-gradient(145deg, var(--accent, var(--gold-deep)), color-mix(in srgb, var(--accent, var(--gold-deep)) 60%, black));
|
|
||||||
border: 3px solid var(--ink);
|
border: 3px solid var(--ink);
|
||||||
box-shadow: inset 0 0 0 2px rgba(255,255,255,0.25);
|
box-shadow: inset 0 0 0 2px rgba(255,255,255,0.25);
|
||||||
}
|
}
|
||||||
@@ -464,8 +464,12 @@ p { margin: 0 0 1em; color: var(--text-muted); }
|
|||||||
.fruit-card__inner { padding: 1.1rem 1.15rem; }
|
.fruit-card__inner { padding: 1.1rem 1.15rem; }
|
||||||
|
|
||||||
.fruit-card__icon {
|
.fruit-card__icon {
|
||||||
font-size: 2rem;
|
width: 56px;
|
||||||
margin-bottom: 0.6rem;
|
height: 56px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.7rem;
|
||||||
|
box-shadow: 0 0 0 1px var(--surface-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fruit-card__inner h3 {
|
.fruit-card__inner h3 {
|
||||||
@@ -536,13 +540,7 @@ p { margin: 0 0 1em; color: var(--text-muted); }
|
|||||||
height: 110px;
|
height: 110px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
overflow: hidden;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 2.6rem;
|
|
||||||
color: #fff;
|
|
||||||
background: linear-gradient(145deg, var(--accent, var(--gold)), color-mix(in srgb, var(--accent, var(--gold)) 55%, black));
|
|
||||||
border: 4px solid var(--bg);
|
border: 4px solid var(--bg);
|
||||||
box-shadow: 0 0 0 3px var(--accent, var(--gold)), var(--shadow-sm);
|
box-shadow: 0 0 0 3px var(--accent, var(--gold)), var(--shadow-sm);
|
||||||
}
|
}
|
||||||
@@ -625,7 +623,14 @@ p { margin: 0 0 1em; color: var(--text-muted); }
|
|||||||
|
|
||||||
.fruit-callout:hover { transform: translateX(2px); border-color: var(--gold); }
|
.fruit-callout:hover { transform: translateX(2px); border-color: var(--gold); }
|
||||||
|
|
||||||
.fruit-callout__icon { font-size: 2.4rem; flex-shrink: 0; }
|
.fruit-callout__icon {
|
||||||
|
width: 68px;
|
||||||
|
height: 68px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 0 0 1px var(--surface-border-strong);
|
||||||
|
}
|
||||||
|
|
||||||
.fruit-callout__label {
|
.fruit-callout__label {
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
@@ -708,13 +713,7 @@ p { margin: 0 0 1em; color: var(--text-muted); }
|
|||||||
width: 38px;
|
width: 38px;
|
||||||
height: 38px;
|
height: 38px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
overflow: hidden;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-family: var(--font-display);
|
|
||||||
font-size: 1.1rem;
|
|
||||||
color: #fff;
|
|
||||||
background: linear-gradient(145deg, var(--accent, var(--gold)), color-mix(in srgb, var(--accent, var(--gold)) 55%, black));
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -735,8 +734,12 @@ p { margin: 0 0 1em; color: var(--text-muted); }
|
|||||||
}
|
}
|
||||||
|
|
||||||
.fruit-detail-header__icon {
|
.fruit-detail-header__icon {
|
||||||
font-size: 4rem;
|
width: 100px;
|
||||||
|
height: 100px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 0 0 3px var(--cat-color, var(--gold)), var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.fruit-detail-header h1 { margin-bottom: 0.2rem; }
|
.fruit-detail-header h1 { margin-bottom: 0.2rem; }
|
||||||
@@ -783,3 +786,35 @@ p { margin: 0 0 1em; color: var(--text-muted); }
|
|||||||
.fruit-detail-header { padding: 1.25rem; }
|
.fruit-detail-header { padding: 1.25rem; }
|
||||||
.filter-meta { margin-left: 0; width: 100%; }
|
.filter-meta { margin-left: 0; width: 100%; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ---------- Proper wiki images ---------- */
|
||||||
|
.proper-image {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: #0b1220;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proper-image__fallback,
|
||||||
|
.proper-image__img {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proper-image__img {
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top center;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proper-image--fruit .proper-image__img {
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
|
padding: 0;
|
||||||
|
background: #0b1220;
|
||||||
|
}
|
||||||
|
|
||||||
|
.proper-image.is-loaded .proper-image__fallback { opacity: 0; }
|
||||||
|
|||||||
@@ -34,6 +34,7 @@
|
|||||||
|
|
||||||
<script src="js/characters-data.js"></script>
|
<script src="js/characters-data.js"></script>
|
||||||
<script src="js/fruits-data.js"></script>
|
<script src="js/fruits-data.js"></script>
|
||||||
|
<script src="js/portraits.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
<script src="js/fruit.js"></script>
|
<script src="js/fruit.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
|
|
||||||
<script src="js/characters-data.js"></script>
|
<script src="js/characters-data.js"></script>
|
||||||
<script src="js/fruits-data.js"></script>
|
<script src="js/fruits-data.js"></script>
|
||||||
|
<script src="js/portraits.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
<script src="js/fruits.js"></script>
|
<script src="js/fruits.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -62,6 +62,7 @@
|
|||||||
|
|
||||||
<script src="js/characters-data.js"></script>
|
<script src="js/characters-data.js"></script>
|
||||||
<script src="js/fruits-data.js"></script>
|
<script src="js/fruits-data.js"></script>
|
||||||
|
<script src="js/portraits.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
<script src="js/main.js"></script>
|
<script src="js/main.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
23
js/app.js
23
js/app.js
@@ -36,11 +36,6 @@ function accentFor(entity) {
|
|||||||
return `hsl(${hashHue(entity.name || key)}, 58%, 48%)`;
|
return `hsl(${hashHue(entity.name || key)}, 58%, 48%)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function initialFor(name) {
|
|
||||||
const match = (name || '?').match(/[A-Za-z]/);
|
|
||||||
return match ? match[0].toUpperCase() : '?';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatBounty(amount) {
|
function formatBounty(amount) {
|
||||||
if (amount === null || amount === undefined) return 'Unknown';
|
if (amount === null || amount === undefined) return 'Unknown';
|
||||||
return '฿' + amount.toLocaleString('en-US');
|
return '฿' + amount.toLocaleString('en-US');
|
||||||
@@ -79,6 +74,16 @@ function statusBadgeClass(status) {
|
|||||||
return 'badge--unknown';
|
return 'badge--unknown';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function characterPosterLabel(c) {
|
||||||
|
if (c.bounty !== null && c.bounty !== undefined) return 'WANTED';
|
||||||
|
if (/marine/i.test(c.affiliation || '')) return 'MARINE';
|
||||||
|
return (c.affiliation || c.role || 'UNKNOWN').split(/[/(]/)[0].trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function characterBountyOrRank(c) {
|
||||||
|
return c.bounty !== null && c.bounty !== undefined ? formatBounty(c.bounty) : c.role || 'Rank unknown';
|
||||||
|
}
|
||||||
|
|
||||||
function characterCardHTML(c) {
|
function characterCardHTML(c) {
|
||||||
const accent = accentFor(c);
|
const accent = accentFor(c);
|
||||||
const deadRibbon = /deceased/i.test(c.status || '') ? '<span class="char-card__status">Deceased</span>' : '';
|
const deadRibbon = /deceased/i.test(c.status || '') ? '<span class="char-card__status">Deceased</span>' : '';
|
||||||
@@ -88,13 +93,13 @@ function characterCardHTML(c) {
|
|||||||
<a href="${characterLink(c.id)}" aria-label="View ${escapeHtml(c.name)}">
|
<a href="${characterLink(c.id)}" aria-label="View ${escapeHtml(c.name)}">
|
||||||
<div class="char-card__poster">
|
<div class="char-card__poster">
|
||||||
${deadRibbon}
|
${deadRibbon}
|
||||||
<span class="char-card__wanted">WANTED</span>
|
<span class="char-card__wanted">${escapeHtml(characterPosterLabel(c))}</span>
|
||||||
<div class="char-card__avatar">${initialFor(c.name)}</div>
|
<div class="char-card__avatar">${characterPortraitSVG(c)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="char-card__body">
|
<div class="char-card__body">
|
||||||
<h3>${escapeHtml(c.name)}</h3>
|
<h3>${escapeHtml(c.name)}</h3>
|
||||||
<p class="char-card__epithet">${c.epithet ? '"' + escapeHtml(c.epithet) + '"' : ' '}</p>
|
<p class="char-card__epithet">${c.epithet ? '"' + escapeHtml(c.epithet) + '"' : ' '}</p>
|
||||||
<p class="char-card__bounty">${c.bounty ? formatBounty(c.bounty) : 'Bounty Unknown'}</p>
|
<p class="char-card__bounty">${escapeHtml(characterBountyOrRank(c))}</p>
|
||||||
<div class="char-card__tags">
|
<div class="char-card__tags">
|
||||||
<span class="tag">${escapeHtml(c.affiliation)}</span>
|
<span class="tag">${escapeHtml(c.affiliation)}</span>
|
||||||
${fruitTag}
|
${fruitTag}
|
||||||
@@ -112,7 +117,7 @@ function fruitCardHTML(f) {
|
|||||||
<article class="fruit-card" data-cat="${escapeHtml(f.category)}">
|
<article class="fruit-card" data-cat="${escapeHtml(f.category)}">
|
||||||
<a href="${fruitLink(f.id)}" aria-label="View ${escapeHtml(f.name)}">
|
<a href="${fruitLink(f.id)}" aria-label="View ${escapeHtml(f.name)}">
|
||||||
<div class="fruit-card__inner">
|
<div class="fruit-card__inner">
|
||||||
<div class="fruit-card__icon">${f.icon}</div>
|
<div class="fruit-card__icon">${fruitPortraitSVG(f)}</div>
|
||||||
<h3>${escapeHtml(f.name)}</h3>
|
<h3>${escapeHtml(f.name)}</h3>
|
||||||
<span class="fruit-card__type">${escapeHtml(f.category)}${f.subtype ? ' · ' + escapeHtml(f.subtype.split(' —')[0].split(' (')[0]) : ''}</span>
|
<span class="fruit-card__type">${escapeHtml(f.category)}${f.subtype ? ' · ' + escapeHtml(f.subtype.split(' —')[0].split(' (')[0]) : ''}</span>
|
||||||
<p class="fruit-card__tagline">${escapeHtml(f.tagline)}</p>
|
<p class="fruit-card__tagline">${escapeHtml(f.tagline)}</p>
|
||||||
|
|||||||
@@ -35,14 +35,14 @@
|
|||||||
|
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<section class="profile-header" style="--accent:${accent}">
|
<section class="profile-header" style="--accent:${accent}">
|
||||||
<div class="profile-avatar">${initialFor(character.name)}</div>
|
<div class="profile-avatar">${characterPortraitSVG(character)}</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="profile-eyebrow">${escapeHtml(character.affiliation)} · ${escapeHtml(character.role)}</p>
|
<p class="profile-eyebrow">${escapeHtml(character.affiliation)} · ${escapeHtml(character.role)}</p>
|
||||||
<h1>${escapeHtml(character.name)}</h1>
|
<h1>${escapeHtml(character.name)}</h1>
|
||||||
<p class="profile-epithet">${character.epithet ? '"' + escapeHtml(character.epithet) + '"' : ''}</p>
|
<p class="profile-epithet">${character.epithet ? '"' + escapeHtml(character.epithet) + '"' : ''}</p>
|
||||||
<div class="profile-badges">
|
<div class="profile-badges">
|
||||||
<span class="badge ${statusBadgeClass(character.status)}">${escapeHtml(character.status || 'Unknown')}</span>
|
<span class="badge ${statusBadgeClass(character.status)}">${escapeHtml(character.status || 'Unknown')}</span>
|
||||||
<span class="badge badge--bounty">${character.bounty ? formatBounty(character.bounty) : 'Bounty Unknown'}</span>
|
<span class="badge badge--bounty">${escapeHtml(characterBountyOrRank(character))}</span>
|
||||||
${fruit ? `<span class="badge" data-cat="${escapeHtml(fruit.category)}" style="color:var(--cat-color)">${escapeHtml(fruit.category)} User</span>` : ''}
|
${fruit ? `<span class="badge" data-cat="${escapeHtml(fruit.category)}" style="color:var(--cat-color)">${escapeHtml(fruit.category)} User</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
<div class="profile-main">
|
<div class="profile-main">
|
||||||
${fruit ? `
|
${fruit ? `
|
||||||
<a class="fruit-callout" href="${fruitLink(fruit.id)}">
|
<a class="fruit-callout" href="${fruitLink(fruit.id)}">
|
||||||
<div class="fruit-callout__icon">${fruit.icon}</div>
|
<div class="fruit-callout__icon">${fruitPortraitSVG(fruit)}</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="fruit-callout__label">${escapeHtml(fruitRelation ? fruitRelation.status : 'Devil Fruit')}</p>
|
<p class="fruit-callout__label">${escapeHtml(fruitRelation ? fruitRelation.status : 'Devil Fruit')}</p>
|
||||||
<h3>${escapeHtml(fruit.name)}</h3>
|
<h3>${escapeHtml(fruit.name)}</h3>
|
||||||
@@ -82,4 +82,6 @@
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
hydrateProperImages(root);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
if (!c) return '';
|
if (!c) return '';
|
||||||
return `
|
return `
|
||||||
<a class="user-chip" href="${characterLink(c.id)}" style="--accent:${accentFor(c)}">
|
<a class="user-chip" href="${characterLink(c.id)}" style="--accent:${accentFor(c)}">
|
||||||
<div class="user-chip__avatar">${initialFor(c.name)}</div>
|
<div class="user-chip__avatar">${characterPortraitSVG(c)}</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="user-chip__name">${escapeHtml(c.name)}</div>
|
<div class="user-chip__name">${escapeHtml(c.name)}</div>
|
||||||
<div class="user-chip__status">${escapeHtml(u.status)}</div>
|
<div class="user-chip__status">${escapeHtml(u.status)}</div>
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
|
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<section class="fruit-detail-header" data-cat="${escapeHtml(fruit.category)}">
|
<section class="fruit-detail-header" data-cat="${escapeHtml(fruit.category)}">
|
||||||
<div class="fruit-detail-header__icon">${fruit.icon}</div>
|
<div class="fruit-detail-header__icon">${fruitPortraitSVG(fruit)}</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="profile-eyebrow" style="color:var(--cat-color)">${escapeHtml(fruit.category)}${fruit.subtype ? ' · ' + escapeHtml(fruit.subtype) : ''}${fruit.awakened ? ' · Awakened' : ''}</p>
|
<p class="profile-eyebrow" style="color:var(--cat-color)">${escapeHtml(fruit.category)}${fruit.subtype ? ' · ' + escapeHtml(fruit.subtype) : ''}${fruit.awakened ? ' · Awakened' : ''}</p>
|
||||||
<h1>${escapeHtml(fruit.name)}</h1>
|
<h1>${escapeHtml(fruit.name)}</h1>
|
||||||
@@ -79,4 +79,6 @@
|
|||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
hydrateProperImages(root);
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -33,6 +33,7 @@
|
|||||||
|
|
||||||
renderCardGrid(grid, results, fruitCardHTML, 'No Devil Fruits match your search.');
|
renderCardGrid(grid, results, fruitCardHTML, 'No Devil Fruits match your search.');
|
||||||
resultCount.textContent = `${results.length} of ${FRUITS.length} Devil Fruits`;
|
resultCount.textContent = `${results.length} of ${FRUITS.length} Devil Fruits`;
|
||||||
|
hydrateProperImages(grid);
|
||||||
}
|
}
|
||||||
|
|
||||||
chipRow.addEventListener('click', (e) => {
|
chipRow.addEventListener('click', (e) => {
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
|
|
||||||
renderCardGrid(grid, results, characterCardHTML, 'No characters match your search. Try a different name or affiliation.');
|
renderCardGrid(grid, results, characterCardHTML, 'No characters match your search. Try a different name or affiliation.');
|
||||||
resultCount.textContent = `${results.length} of ${CHARACTERS.length} characters`;
|
resultCount.textContent = `${results.length} of ${CHARACTERS.length} characters`;
|
||||||
|
hydrateProperImages(grid);
|
||||||
}
|
}
|
||||||
|
|
||||||
searchInput.addEventListener('input', applyFilters);
|
searchInput.addEventListener('input', applyFilters);
|
||||||
|
|||||||
228
js/portraits.js
Normal file
228
js/portraits.js
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
/* One Piece Wiki — original, procedurally generated portrait/icon artwork.
|
||||||
|
Every character gets a role-themed line-art medallion; every Devil Fruit gets a
|
||||||
|
stylized fruit-shape icon colored by type. All shapes are drawn from scratch here —
|
||||||
|
no external art or copyrighted imagery is used anywhere in this file. */
|
||||||
|
|
||||||
|
function radialLines(cx, cy, rInner, rOuter, count, strokeWidth) {
|
||||||
|
let out = '';
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const a = (Math.PI * 2 * i) / count;
|
||||||
|
const x1 = cx + rInner * Math.cos(a), y1 = cy + rInner * Math.sin(a);
|
||||||
|
const x2 = cx + rOuter * Math.cos(a), y2 = cy + rOuter * Math.sin(a);
|
||||||
|
out += `<line x1="${x1.toFixed(1)}" y1="${y1.toFixed(1)}" x2="${x2.toFixed(1)}" y2="${y2.toFixed(1)}" stroke-width="${strokeWidth}"/>`;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function starPoints(cx, cy, rOuter, rInner, count) {
|
||||||
|
const pts = [];
|
||||||
|
for (let i = 0; i < count * 2; i++) {
|
||||||
|
const r = i % 2 === 0 ? rOuter : rInner;
|
||||||
|
const a = (Math.PI * i) / count - Math.PI / 2;
|
||||||
|
pts.push(`${(cx + r * Math.cos(a)).toFixed(1)},${(cy + r * Math.sin(a)).toFixed(1)}`);
|
||||||
|
}
|
||||||
|
return pts.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Simple original line-art built from primitive shapes (circles/lines/polygons) — one per archetype. */
|
||||||
|
const ROLE_ICONS = {
|
||||||
|
sword: '<g transform="rotate(45 32 32)"><line x1="32" y1="10" x2="32" y2="42"/><line x1="24" y1="18" x2="40" y2="18"/><line x1="32" y1="42" x2="32" y2="50"/></g><g transform="rotate(-45 32 32)"><line x1="32" y1="10" x2="32" y2="42"/><line x1="24" y1="18" x2="40" y2="18"/><line x1="32" y1="42" x2="32" y2="50"/></g>',
|
||||||
|
compass: `<circle cx="32" cy="32" r="18"/>${radialLines(32, 32, 15, 18, 12, 2)}<polygon points="32,21 37,32 32,43 27,32" fill="currentColor" stroke="none"/>`,
|
||||||
|
target: '<circle cx="32" cy="32" r="18"/><circle cx="32" cy="32" r="11"/><circle cx="32" cy="32" r="4" fill="currentColor" stroke="none"/>',
|
||||||
|
cross: '<line x1="32" y1="14" x2="32" y2="50"/><line x1="14" y1="32" x2="50" y2="32"/>',
|
||||||
|
scroll: '<rect x="16" y="20" width="32" height="24" rx="2"/><circle cx="16" cy="20" r="4"/><circle cx="16" cy="44" r="4"/><circle cx="48" cy="20" r="4"/><circle cx="48" cy="44" r="4"/><line x1="22" y1="28" x2="42" y2="28"/><line x1="22" y1="36" x2="42" y2="36"/>',
|
||||||
|
gear: `<circle cx="32" cy="32" r="9"/>${radialLines(32, 32, 12, 18, 8, 3)}`,
|
||||||
|
note: '<circle cx="23" cy="46" r="6"/><circle cx="43" cy="42" r="6"/><line x1="29" y1="46" x2="29" y2="16"/><line x1="49" y1="42" x2="49" y2="14"/><line x1="29" y1="16" x2="49" y2="14"/><line x1="29" y1="24" x2="49" y2="22"/>',
|
||||||
|
chefhat: '<path d="M20 44 h24 v6 h-24 z"/><path d="M22 44 C18 30 22 18 32 18 C34 12 44 12 46 18 C54 20 54 32 46 36 C46 40 46 42 44 44 h-22 C20 42 20 44 22 44 Z"/>',
|
||||||
|
flask: '<line x1="26" y1="12" x2="26" y2="26"/><line x1="38" y1="12" x2="38" y2="26"/><line x1="24" y1="12" x2="40" y2="12"/><path d="M26 26 L16 48 a4 4 0 0 0 4 6 h24 a4 4 0 0 0 4 -6 L38 26 Z"/><line x1="20" y1="40" x2="44" y2="40"/>',
|
||||||
|
anchor: '<circle cx="32" cy="16" r="5"/><line x1="32" y1="21" x2="32" y2="50"/><line x1="18" y1="30" x2="46" y2="30"/><path d="M18 38 a14 14 0 0 0 14 12"/><path d="M46 38 a14 14 0 0 1 -14 12"/>',
|
||||||
|
crown: '<polygon points="16,44 16,26 24,34 32,20 40,34 48,26 48,44"/><line x1="16" y1="44" x2="48" y2="44"/>',
|
||||||
|
tiara: '<path d="M16 40 Q32 16 48 40"/><circle cx="32" cy="22" r="4" fill="currentColor" stroke="none"/><line x1="16" y1="40" x2="48" y2="40"/>',
|
||||||
|
flag: '<line x1="20" y1="12" x2="20" y2="52"/><path d="M20 14 h26 l-8 9 8 9 h-26 z"/>',
|
||||||
|
dagger: '<line x1="32" y1="12" x2="32" y2="38"/><line x1="24" y1="20" x2="40" y2="20"/><polygon points="32,38 26,46 38,46"/>',
|
||||||
|
skull: '<circle cx="32" cy="28" r="16"/><circle cx="25" cy="26" r="3" fill="currentColor" stroke="none"/><circle cx="39" cy="26" r="3" fill="currentColor" stroke="none"/><path d="M24 38 h16 M26 44 h4 M34 44 h4"/>',
|
||||||
|
paw: '<circle cx="32" cy="38" r="10"/><circle cx="20" cy="24" r="5"/><circle cx="32" cy="18" r="5"/><circle cx="44" cy="24" r="5"/>',
|
||||||
|
wing: '<path d="M14 40 C22 24 30 18 34 30 C38 18 46 24 50 40 C40 34 40 40 34 34 C28 40 24 34 14 40 Z"/>',
|
||||||
|
star: `<polygon points="${starPoints(32, 32, 18, 8, 5)}"/>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLE_ICON_RULES = [
|
||||||
|
[/navigat/, 'compass'],
|
||||||
|
[/sniper|marksman|sharpshoot/, 'target'],
|
||||||
|
[/doctor|medic|surgeon/, 'cross'],
|
||||||
|
[/archaeolog|scholar/, 'scroll'],
|
||||||
|
[/shipwright|engineer|mechanic/, 'gear'],
|
||||||
|
[/musician|singer/, 'note'],
|
||||||
|
[/cook|chef/, 'chefhat'],
|
||||||
|
[/scientist/, 'flask'],
|
||||||
|
[/helmsman|admiral|marine|navy|warden/, 'anchor'],
|
||||||
|
[/king|emperor|empress|yonko|shogun/, 'crown'],
|
||||||
|
[/princess|prince/, 'tiara'],
|
||||||
|
[/revolutionary/, 'flag'],
|
||||||
|
[/agent|spy|officer|assassin|cp0|cp9/, 'dagger'],
|
||||||
|
[/mink|beast|tiger|lion|leopard|wolf|dragon/, 'paw'],
|
||||||
|
[/phoenix|bird|wing|flight/, 'wing'],
|
||||||
|
[/swordsm|santoryu|blade|commander|combatant/, 'sword'],
|
||||||
|
[/captain|warlord|pirate/, 'skull'],
|
||||||
|
];
|
||||||
|
|
||||||
|
function iconKeyForCharacter(c) {
|
||||||
|
const haystack = `${c.role} ${c.epithet || ''} ${c.affiliation}`.toLowerCase();
|
||||||
|
for (const [re, key] of ROLE_ICON_RULES) {
|
||||||
|
if (re.test(haystack)) return key;
|
||||||
|
}
|
||||||
|
return 'star';
|
||||||
|
}
|
||||||
|
|
||||||
|
function characterImageTitle(c) {
|
||||||
|
return c.imageTitle || c.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fruitImageTitle(f) {
|
||||||
|
if (f.imageTitle) return f.imageTitle;
|
||||||
|
if (f.id === 'gomu-gomu-no-mi') return 'Hito Hito no Mi, Model: Nika';
|
||||||
|
return f.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
function properImageHTML(kind, title, label, fallback) {
|
||||||
|
return `
|
||||||
|
<span class="proper-image proper-image--${kind}" data-image-title="${escapeHtml(title)}" data-image-kind="${kind}">
|
||||||
|
<span class="proper-image__fallback">${fallback}</span>
|
||||||
|
<img class="proper-image__img" alt="${escapeHtml(label)}" loading="lazy" referrerpolicy="no-referrer" hidden>
|
||||||
|
</span>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function characterPortraitFallbackSVG(c) {
|
||||||
|
const accent = accentFor(c);
|
||||||
|
const hue2 = (hashHue(c.id + 'x') + 45) % 360;
|
||||||
|
const icon = ROLE_ICONS[iconKeyForCharacter(c)] || ROLE_ICONS.star;
|
||||||
|
const uid = c.id.replace(/[^a-z0-9]/g, '');
|
||||||
|
const rotation = hashHue(c.name) % 90;
|
||||||
|
const fruit = c.devilFruit ? findFruit(c.devilFruit) : null;
|
||||||
|
|
||||||
|
return `
|
||||||
|
<svg viewBox="0 0 64 64" role="img" aria-label="${escapeHtml(c.name)} portrait" class="portrait-svg">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g-${uid}" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="${accent}"/>
|
||||||
|
<stop offset="100%" stop-color="hsl(${hue2}, 55%, 26%)"/>
|
||||||
|
</linearGradient>
|
||||||
|
<pattern id="p-${uid}" width="9" height="9" patternTransform="rotate(${rotation})" patternUnits="userSpaceOnUse">
|
||||||
|
<line x1="0" y1="0" x2="0" y2="9" stroke="rgba(255,255,255,0.09)" stroke-width="2"/>
|
||||||
|
</pattern>
|
||||||
|
</defs>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#g-${uid})"/>
|
||||||
|
<circle cx="32" cy="32" r="32" fill="url(#p-${uid})"/>
|
||||||
|
<circle cx="32" cy="32" r="30.5" fill="none" stroke="rgba(255,255,255,0.18)" stroke-width="1"/>
|
||||||
|
<g fill="none" stroke="#fdf6e3" stroke-width="3.1" stroke-linecap="round" stroke-linejoin="round" opacity="0.95">${icon}</g>
|
||||||
|
${fruit ? `<circle cx="49" cy="49" r="11.5" fill="#0b1220" stroke="#fdf6e3" stroke-width="1.5"/><text x="49" y="53.5" font-size="12" text-anchor="middle">${fruit.icon}</text>` : ''}
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORY_HEX = { Paramecia: '#a06cd5', Zoan: '#4c9a5a', Logia: '#3f9bd0' };
|
||||||
|
|
||||||
|
function fruitPortraitFallbackSVG(f) {
|
||||||
|
const color = CATEGORY_HEX[f.category] || '#e3b04b';
|
||||||
|
const glow = f.subtype && /mythical|special/i.test(f.subtype);
|
||||||
|
const uid = f.id.replace(/[^a-z0-9]/g, '');
|
||||||
|
return `
|
||||||
|
<svg viewBox="0 0 64 64" role="img" aria-label="${escapeHtml(f.name)} icon" class="portrait-svg">
|
||||||
|
<defs>
|
||||||
|
<radialGradient id="fg-${uid}" cx="35%" cy="28%" r="80%">
|
||||||
|
<stop offset="0%" stop-color="${color}" stop-opacity="0.9"/>
|
||||||
|
<stop offset="100%" stop-color="${color}" stop-opacity="0.28"/>
|
||||||
|
</radialGradient>
|
||||||
|
</defs>
|
||||||
|
<circle cx="32" cy="34" r="32" fill="#0b1220"/>
|
||||||
|
<path d="M32 14 C21 14 13 23 13 34 C13 47 21 54 32 54 C43 54 51 47 51 34 C51 23 43 14 32 14 Z"
|
||||||
|
fill="url(#fg-${uid})" stroke="${glow ? '#e3b04b' : color}" stroke-width="2.2"/>
|
||||||
|
<path d="M32 14 C30 9 31 5 36 3" fill="none" stroke="#7a9b57" stroke-width="3" stroke-linecap="round"/>
|
||||||
|
<path d="M20 27 C25 23 39 23 44 27" fill="none" stroke="rgba(255,255,255,0.32)" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<path d="M22 40 C27 44 37 44 42 40" fill="none" stroke="rgba(0,0,0,0.18)" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<text x="32" y="41" font-size="19" text-anchor="middle">${f.icon}</text>
|
||||||
|
</svg>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function characterPortraitSVG(c) {
|
||||||
|
return properImageHTML('character', characterImageTitle(c), `${c.name} portrait`, characterPortraitFallbackSVG(c));
|
||||||
|
}
|
||||||
|
|
||||||
|
function fruitPortraitSVG(f) {
|
||||||
|
return properImageHTML('fruit', fruitImageTitle(f), `${f.name} fruit`, fruitPortraitFallbackSVG(f));
|
||||||
|
}
|
||||||
|
|
||||||
|
const IMAGE_CACHE_KEY = 'opwikiProperImages:v1';
|
||||||
|
const IMAGE_API_ENDPOINT = 'https://onepiece.fandom.com/api.php';
|
||||||
|
let properImageCache = null;
|
||||||
|
|
||||||
|
function getProperImageCache() {
|
||||||
|
if (properImageCache) return properImageCache;
|
||||||
|
try {
|
||||||
|
properImageCache = JSON.parse(localStorage.getItem(IMAGE_CACHE_KEY) || '{}');
|
||||||
|
} catch (_) {
|
||||||
|
properImageCache = {};
|
||||||
|
}
|
||||||
|
return properImageCache;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveProperImageCache() {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(IMAGE_CACHE_KEY, JSON.stringify(getProperImageCache()));
|
||||||
|
} catch (_) {
|
||||||
|
// Non-critical: images still load for the current page if storage is unavailable.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wikiThumbnailUrl(title, size = 700) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
action: 'query',
|
||||||
|
prop: 'pageimages',
|
||||||
|
format: 'json',
|
||||||
|
piprop: 'thumbnail',
|
||||||
|
pithumbsize: String(size),
|
||||||
|
redirects: '1',
|
||||||
|
origin: '*',
|
||||||
|
titles: title,
|
||||||
|
});
|
||||||
|
return `${IMAGE_API_ENDPOINT}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveProperImage(title) {
|
||||||
|
const cache = getProperImageCache();
|
||||||
|
if (Object.prototype.hasOwnProperty.call(cache, title)) return cache[title];
|
||||||
|
|
||||||
|
const response = await fetch(wikiThumbnailUrl(title));
|
||||||
|
if (!response.ok) throw new Error(`Image lookup failed for ${title}`);
|
||||||
|
const data = await response.json();
|
||||||
|
const page = Object.values(data.query?.pages || {})[0];
|
||||||
|
const source = page?.thumbnail?.source || '';
|
||||||
|
cache[title] = source;
|
||||||
|
saveProperImageCache();
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hydrateProperImages(root = document) {
|
||||||
|
const holders = [...root.querySelectorAll('.proper-image[data-image-title]')];
|
||||||
|
holders.forEach(async (holder) => {
|
||||||
|
const img = holder.querySelector('.proper-image__img');
|
||||||
|
if (!img || holder.dataset.loaded) return;
|
||||||
|
holder.dataset.loaded = 'pending';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const source = await resolveProperImage(holder.dataset.imageTitle);
|
||||||
|
if (!source) throw new Error('No thumbnail returned');
|
||||||
|
img.addEventListener('load', () => {
|
||||||
|
holder.classList.add('is-loaded');
|
||||||
|
img.hidden = false;
|
||||||
|
}, { once: true });
|
||||||
|
img.src = source;
|
||||||
|
holder.dataset.loaded = 'true';
|
||||||
|
} catch (_) {
|
||||||
|
holder.dataset.loaded = 'fallback';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user