mirror of
https://github.com/Cametendo/devil-fruit-encyclopedia.git
synced 2026-09-13 15:59:48 +02:00
Compare commits
1 Commits
claude/one
...
edf8130b00
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edf8130b00 |
@@ -34,7 +34,6 @@
|
||||
|
||||
<script src="js/characters-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/character.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -367,18 +367,18 @@ p { margin: 0 0 1em; color: var(--text-muted); }
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.portrait-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.char-card__avatar {
|
||||
width: 84px;
|
||||
height: 84px;
|
||||
margin: 0 auto 0.5rem;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
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);
|
||||
box-shadow: inset 0 0 0 2px rgba(255,255,255,0.25);
|
||||
}
|
||||
@@ -464,12 +464,8 @@ p { margin: 0 0 1em; color: var(--text-muted); }
|
||||
.fruit-card__inner { padding: 1.1rem 1.15rem; }
|
||||
|
||||
.fruit-card__icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.7rem;
|
||||
box-shadow: 0 0 0 1px var(--surface-border);
|
||||
font-size: 2rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.fruit-card__inner h3 {
|
||||
@@ -540,7 +536,13 @@ p { margin: 0 0 1em; color: var(--text-muted); }
|
||||
height: 110px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
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);
|
||||
box-shadow: 0 0 0 3px var(--accent, var(--gold)), var(--shadow-sm);
|
||||
}
|
||||
@@ -623,14 +625,7 @@ p { margin: 0 0 1em; color: var(--text-muted); }
|
||||
|
||||
.fruit-callout:hover { transform: translateX(2px); border-color: var(--gold); }
|
||||
|
||||
.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__icon { font-size: 2.4rem; flex-shrink: 0; }
|
||||
|
||||
.fruit-callout__label {
|
||||
font-size: 0.72rem;
|
||||
@@ -713,7 +708,13 @@ p { margin: 0 0 1em; color: var(--text-muted); }
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -734,12 +735,8 @@ p { margin: 0 0 1em; color: var(--text-muted); }
|
||||
}
|
||||
|
||||
.fruit-detail-header__icon {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
font-size: 4rem;
|
||||
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; }
|
||||
@@ -786,35 +783,3 @@ p { margin: 0 0 1em; color: var(--text-muted); }
|
||||
.fruit-detail-header { padding: 1.25rem; }
|
||||
.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,7 +34,6 @@
|
||||
|
||||
<script src="js/characters-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/fruit.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -60,7 +60,6 @@
|
||||
|
||||
<script src="js/characters-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/fruits.js"></script>
|
||||
</body>
|
||||
|
||||
@@ -42,8 +42,7 @@
|
||||
<select id="affiliation-select" class="select">
|
||||
<option value="">All affiliations</option>
|
||||
</select>
|
||||
<label class="checkbox"><input type="checkbox" id="fruit-only-checkbox" autocomplete="off" /> Devil Fruit users only</label>
|
||||
<label class="checkbox"><input type="checkbox" id="deceased-only-checkbox" autocomplete="off" /> Deceased only</label>
|
||||
<label class="checkbox"><input type="checkbox" id="fruit-only-checkbox" /> Devil Fruit users only</label>
|
||||
<span class="filter-meta" id="result-count"></span>
|
||||
</div>
|
||||
</section>
|
||||
@@ -63,7 +62,6 @@
|
||||
|
||||
<script src="js/characters-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/main.js"></script>
|
||||
</body>
|
||||
|
||||
23
js/app.js
23
js/app.js
@@ -36,6 +36,11 @@ function accentFor(entity) {
|
||||
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) {
|
||||
if (amount === null || amount === undefined) return 'Unknown';
|
||||
return '฿' + amount.toLocaleString('en-US');
|
||||
@@ -74,16 +79,6 @@ function statusBadgeClass(status) {
|
||||
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) {
|
||||
const accent = accentFor(c);
|
||||
const deadRibbon = /deceased/i.test(c.status || '') ? '<span class="char-card__status">Deceased</span>' : '';
|
||||
@@ -93,13 +88,13 @@ function characterCardHTML(c) {
|
||||
<a href="${characterLink(c.id)}" aria-label="View ${escapeHtml(c.name)}">
|
||||
<div class="char-card__poster">
|
||||
${deadRibbon}
|
||||
<span class="char-card__wanted">${escapeHtml(characterPosterLabel(c))}</span>
|
||||
<div class="char-card__avatar">${characterPortraitSVG(c)}</div>
|
||||
<span class="char-card__wanted">WANTED</span>
|
||||
<div class="char-card__avatar">${initialFor(c.name)}</div>
|
||||
</div>
|
||||
<div class="char-card__body">
|
||||
<h3>${escapeHtml(c.name)}</h3>
|
||||
<p class="char-card__epithet">${c.epithet ? '"' + escapeHtml(c.epithet) + '"' : ' '}</p>
|
||||
<p class="char-card__bounty">${escapeHtml(characterBountyOrRank(c))}</p>
|
||||
<p class="char-card__bounty">${c.bounty ? formatBounty(c.bounty) : 'Bounty Unknown'}</p>
|
||||
<div class="char-card__tags">
|
||||
<span class="tag">${escapeHtml(c.affiliation)}</span>
|
||||
${fruitTag}
|
||||
@@ -117,7 +112,7 @@ function fruitCardHTML(f) {
|
||||
<article class="fruit-card" data-cat="${escapeHtml(f.category)}">
|
||||
<a href="${fruitLink(f.id)}" aria-label="View ${escapeHtml(f.name)}">
|
||||
<div class="fruit-card__inner">
|
||||
<div class="fruit-card__icon">${fruitPortraitSVG(f)}</div>
|
||||
<div class="fruit-card__icon">${f.icon}</div>
|
||||
<h3>${escapeHtml(f.name)}</h3>
|
||||
<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>
|
||||
|
||||
@@ -35,14 +35,14 @@
|
||||
|
||||
root.innerHTML = `
|
||||
<section class="profile-header" style="--accent:${accent}">
|
||||
<div class="profile-avatar">${characterPortraitSVG(character)}</div>
|
||||
<div class="profile-avatar">${initialFor(character.name)}</div>
|
||||
<div>
|
||||
<p class="profile-eyebrow">${escapeHtml(character.affiliation)} · ${escapeHtml(character.role)}</p>
|
||||
<h1>${escapeHtml(character.name)}</h1>
|
||||
<p class="profile-epithet">${character.epithet ? '"' + escapeHtml(character.epithet) + '"' : ''}</p>
|
||||
<div class="profile-badges">
|
||||
<span class="badge ${statusBadgeClass(character.status)}">${escapeHtml(character.status || 'Unknown')}</span>
|
||||
<span class="badge badge--bounty">${escapeHtml(characterBountyOrRank(character))}</span>
|
||||
<span class="badge badge--bounty">${character.bounty ? formatBounty(character.bounty) : 'Bounty Unknown'}</span>
|
||||
${fruit ? `<span class="badge" data-cat="${escapeHtml(fruit.category)}" style="color:var(--cat-color)">${escapeHtml(fruit.category)} User</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
@@ -52,7 +52,7 @@
|
||||
<div class="profile-main">
|
||||
${fruit ? `
|
||||
<a class="fruit-callout" href="${fruitLink(fruit.id)}">
|
||||
<div class="fruit-callout__icon">${fruitPortraitSVG(fruit)}</div>
|
||||
<div class="fruit-callout__icon">${fruit.icon}</div>
|
||||
<div>
|
||||
<p class="fruit-callout__label">${escapeHtml(fruitRelation ? fruitRelation.status : 'Devil Fruit')}</p>
|
||||
<h3>${escapeHtml(fruit.name)}</h3>
|
||||
@@ -82,6 +82,4 @@
|
||||
</aside>
|
||||
</div>
|
||||
`;
|
||||
|
||||
hydrateProperImages(root);
|
||||
})();
|
||||
|
||||
@@ -801,13 +801,5 @@ const CHARACTERS = [
|
||||
devilFruit: null, firstAppearance: 'Chapter 655, "Kinemon of the Foxfire"',
|
||||
abilities: ['Occasional forced dragon transformation (via SMILE-derived drug)', 'Rightful claim to Wano\'s shogunate'],
|
||||
bio: 'The son of Kozuki Oden, thrust into leadership as Wano\'s new shogun after twenty years frozen in time by Toki\'s Devil Fruit. Momonosuke\'s reluctant, often terrified heroism during the raid on Onigashima, including flying into battle as a dragon, marked his growth into the heir his father hoped he would become.'
|
||||
},
|
||||
{
|
||||
id: 'uta', name: 'Uta', epithet: "World's Greatest Diva",
|
||||
affiliation: 'Independent (raised by the Red Hair Pirates)', role: 'Singer', origin: "Raised aboard Shanks' ship, later Elbaf",
|
||||
age: 18, height: '156 cm', birthday: 'March 8', bounty: null, status: 'Deceased',
|
||||
devilFruit: 'uta-uta-no-mi', firstAppearance: 'One Piece Film: Red (2022)',
|
||||
abilities: ['Uta Uta no Mi (trapping listeners inside Uta no Sekai)', 'World-renowned singing voice', 'Advanced physical combat (implied Haki)'],
|
||||
bio: "The secret daughter of \"Red-Haired\" Shanks and Luffy's closest childhood friend, hidden away and raised on Elbaf after Shanks told her he had died, so she would never be tempted to follow him into a pirate's life. Uta grew up to become the most famous singer in the world, performing behind a hidden identity. At her debut concert on the Island of Elegia, grief and a desire to end all suffering drove her to use her Devil Fruit to try to trap humanity inside a permanent dream world, a plan called \"New Genesis\" that only Luffy — and her father — could stop. She died in Shanks' arms soon after, her heart unable to bear the strain the power had placed on it since childhood. (Appears in One Piece Film: Red, a movie storyline outside the main manga/anime continuity.)"
|
||||
}
|
||||
];
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
if (!c) return '';
|
||||
return `
|
||||
<a class="user-chip" href="${characterLink(c.id)}" style="--accent:${accentFor(c)}">
|
||||
<div class="user-chip__avatar">${characterPortraitSVG(c)}</div>
|
||||
<div class="user-chip__avatar">${initialFor(c.name)}</div>
|
||||
<div>
|
||||
<div class="user-chip__name">${escapeHtml(c.name)}</div>
|
||||
<div class="user-chip__status">${escapeHtml(u.status)}</div>
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
root.innerHTML = `
|
||||
<section class="fruit-detail-header" data-cat="${escapeHtml(fruit.category)}">
|
||||
<div class="fruit-detail-header__icon">${fruitPortraitSVG(fruit)}</div>
|
||||
<div class="fruit-detail-header__icon">${fruit.icon}</div>
|
||||
<div>
|
||||
<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>
|
||||
@@ -79,6 +79,4 @@
|
||||
</aside>
|
||||
</div>
|
||||
`;
|
||||
|
||||
hydrateProperImages(root);
|
||||
})();
|
||||
|
||||
@@ -952,22 +952,5 @@ const FRUITS = [
|
||||
strengths: "Combines huge physical scale with air-splitting shockwave attacks, ideal for large-scale battlefield control.",
|
||||
weaknesses: "The form's size can be unwieldy in enclosed spaces or against fast, evasive opponents.",
|
||||
users: [{ id: 'sengoku', status: 'Current user' }]
|
||||
},
|
||||
{
|
||||
id: 'uta-uta-no-mi', name: 'Uta Uta no Mi', englishName: 'Song-Song Fruit',
|
||||
category: 'Paramecia', subtype: null, awakened: false, icon: '🎤',
|
||||
meaning: '"Uta" — song',
|
||||
tagline: "Lets the user pull anyone who hears them sing into a shared dream world of their own making.",
|
||||
description: "A Paramecia fruit that turns the user's singing voice into a gateway: anyone who hears the song can be drawn, body and mind, into an internal dream world the user controls — called Uta no Sekai — with the line between dream and reality blurring the longer a victim stays trapped inside.",
|
||||
history: "The fruit's history before Uta is unrecorded. She ate it as a child growing up in secret aboard the Red Hair Pirates' ship, and an early, uncontrolled use of its power left her with a heart weakened for the rest of her life, a risk Shanks chose to shield her from rather than explain.\n\nYears later, performing as the world's most famous singer under a hidden identity, Uta used the fruit at her debut concert on the Island of Elegia to try to pull all of humanity permanently into Uta no Sekai as part of a plan she called \"New Genesis,\" believing a shared dream was kinder than a world full of pain. Monkey D. Luffy fought his way through the dream world to reach her and broke the technique, freeing everyone trapped inside — though the toll on Uta's own heart proved fatal soon after. (Introduced in One Piece Film: Red, a movie storyline outside the main manga/anime continuity.)",
|
||||
powers: [
|
||||
"Pulling anyone who hears the user sing into a shared dream world (Uta no Sekai)",
|
||||
"Full sensory and bodily control over victims while they remain inside the dream",
|
||||
"The dream world gradually merges with reality the longer victims stay trapped",
|
||||
"Sufficiently powerful real-world attacks reaching a victim can forcibly eject them from the dream"
|
||||
],
|
||||
strengths: "Can incapacitate enormous crowds simultaneously with nothing but a song, requiring no direct contact.",
|
||||
weaknesses: "Physically taxing on the user's own body and heart; victims can potentially be shocked back to reality by a strong enough attack.",
|
||||
users: [{ id: 'uta', status: 'Current user (deceased)' }]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -33,7 +33,6 @@
|
||||
|
||||
renderCardGrid(grid, results, fruitCardHTML, 'No Devil Fruits match your search.');
|
||||
resultCount.textContent = `${results.length} of ${FRUITS.length} Devil Fruits`;
|
||||
hydrateProperImages(grid);
|
||||
}
|
||||
|
||||
chipRow.addEventListener('click', (e) => {
|
||||
|
||||
11
js/main.js
11
js/main.js
@@ -1,17 +1,12 @@
|
||||
/* Home page: character grid with search + affiliation + devil-fruit + deceased filters. */
|
||||
/* Home page: character grid with search + affiliation + devil-fruit filters. */
|
||||
|
||||
(function () {
|
||||
const grid = document.getElementById('character-grid');
|
||||
const searchInput = document.getElementById('search-input');
|
||||
const affiliationSelect = document.getElementById('affiliation-select');
|
||||
const fruitOnlyCheckbox = document.getElementById('fruit-only-checkbox');
|
||||
const deceasedOnlyCheckbox = document.getElementById('deceased-only-checkbox');
|
||||
const resultCount = document.getElementById('result-count');
|
||||
|
||||
// Browsers may restore checkbox state across reloads; start with the full roster visible.
|
||||
fruitOnlyCheckbox.checked = false;
|
||||
deceasedOnlyCheckbox.checked = false;
|
||||
|
||||
document.getElementById('stat-characters').textContent = CHARACTERS.length;
|
||||
document.getElementById('stat-fruits').textContent = FRUITS.length;
|
||||
|
||||
@@ -27,11 +22,9 @@
|
||||
const term = searchInput.value.trim().toLowerCase();
|
||||
const affiliation = affiliationSelect.value;
|
||||
const fruitOnly = fruitOnlyCheckbox.checked;
|
||||
const deceasedOnly = deceasedOnlyCheckbox.checked;
|
||||
|
||||
const results = CHARACTERS.filter((c) => {
|
||||
if (fruitOnly && !c.devilFruit) return false;
|
||||
if (deceasedOnly && !/deceased/i.test(c.status || '')) return false;
|
||||
if (affiliation && c.affiliation !== affiliation) return false;
|
||||
if (term) {
|
||||
const haystack = `${c.name} ${c.epithet || ''} ${c.affiliation} ${c.role}`.toLowerCase();
|
||||
@@ -42,13 +35,11 @@
|
||||
|
||||
renderCardGrid(grid, results, characterCardHTML, 'No characters match your search. Try a different name or affiliation.');
|
||||
resultCount.textContent = `${results.length} of ${CHARACTERS.length} characters`;
|
||||
hydrateProperImages(grid);
|
||||
}
|
||||
|
||||
searchInput.addEventListener('input', applyFilters);
|
||||
affiliationSelect.addEventListener('change', applyFilters);
|
||||
fruitOnlyCheckbox.addEventListener('change', applyFilters);
|
||||
deceasedOnlyCheckbox.addEventListener('change', applyFilters);
|
||||
|
||||
applyFilters();
|
||||
})();
|
||||
|
||||
228
js/portraits.js
228
js/portraits.js
@@ -1,228 +0,0 @@
|
||||
/* 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