275 lines
9.3 KiB
JavaScript
275 lines
9.3 KiB
JavaScript
import { searchWorlds, calcParsecs } from './travellerMapApi.js';
|
|
|
|
const MODULE_ID = 'mgt2-compendium-amiral-denisov';
|
|
const { ApplicationV2, HandlebarsApplicationMixin } = foundry.applications.api;
|
|
|
|
function cleanSectorName(sector) {
|
|
return sector?.replace(/\s*\([^)]*\)\s*$/, '').trim() || sector;
|
|
}
|
|
|
|
function worldCoord(sx, sy, hx, hy) {
|
|
return { x: (sx - 0) * 32 + (hx - 1), y: (sy - 0) * 40 + (hy - 40) };
|
|
}
|
|
|
|
export class TravelDialog extends HandlebarsApplicationMixin(ApplicationV2) {
|
|
static DEFAULT_OPTIONS = {
|
|
id: 'mgt2-travel-dialog',
|
|
classes: ['mgt2-travel-dialog'],
|
|
position: { width: 600, height: 500 },
|
|
window: { icon: 'fas fa-route', title: 'Planificateur de voyage', resizable: true, controls: [] },
|
|
};
|
|
|
|
static PARTS = {
|
|
main: {
|
|
template: `modules/${MODULE_ID}/templates/travel-dialog.hbs`,
|
|
},
|
|
};
|
|
|
|
constructor() {
|
|
super();
|
|
this._fromWorld = null;
|
|
this._toWorld = null;
|
|
this._lastWorlds = null;
|
|
this._lastSegments = null;
|
|
}
|
|
|
|
_onRender(context, options) {
|
|
this._wireSearch('from');
|
|
this._wireSearch('to');
|
|
this.element?.querySelector('[data-action="calculate"]')?.addEventListener('click', () => {
|
|
this._calculateRoute();
|
|
});
|
|
this.element?.querySelector('[data-action="create-journal"]')?.addEventListener('click', () => {
|
|
this._createJournal();
|
|
});
|
|
}
|
|
|
|
_wireSearch(prefix) {
|
|
const input = this.element.querySelector(`[name="travel-${prefix}"]`);
|
|
const results = this.element.querySelector(`.travel-${prefix}-results`);
|
|
if (!input || !results) return;
|
|
|
|
let timeout = null;
|
|
input.addEventListener('input', () => {
|
|
clearTimeout(timeout);
|
|
const q = input.value.trim();
|
|
if (q.length < 2) { results.innerHTML = ''; return; }
|
|
timeout = setTimeout(async () => {
|
|
const worlds = await searchWorlds(q);
|
|
if (!worlds?.length) {
|
|
results.innerHTML = '<li class="no-result">Aucun résultat</li>';
|
|
return;
|
|
}
|
|
results.innerHTML = worlds.slice(0, 10).map(w =>
|
|
`<li data-sector="${w.sector}" data-hex="${w.hex}" data-name="${w.name}">
|
|
<span class="world-name">${w.name}</span>
|
|
<span class="world-sector">${w.sector}</span>
|
|
<span class="world-hex">${w.hex}</span>
|
|
</li>`
|
|
).join('');
|
|
}, 300);
|
|
});
|
|
|
|
results.addEventListener('click', (e) => {
|
|
const li = e.target.closest('[data-sector]');
|
|
if (!li) return;
|
|
const data = { sector: li.dataset.sector, hex: li.dataset.hex, name: li.dataset.name };
|
|
if (prefix === 'from') this._fromWorld = data;
|
|
else this._toWorld = data;
|
|
input.value = `${data.name} (${data.sector} ${data.hex})`;
|
|
results.innerHTML = '';
|
|
});
|
|
|
|
input.addEventListener('blur', () => {
|
|
setTimeout(() => { results.innerHTML = ''; }, 200);
|
|
});
|
|
}
|
|
|
|
async _calculateRoute() {
|
|
if (!this._fromWorld || !this._toWorld) {
|
|
ui.notifications.warn('Veuillez sélectionner un monde de départ et un monde d\'arrivée');
|
|
return;
|
|
}
|
|
|
|
const jumpEl = this.element.querySelector('[name="travel-jump"]');
|
|
const jump = parseInt(jumpEl?.value, 10) || 2;
|
|
|
|
const resultsEl = this.element.querySelector('.travel-results');
|
|
if (!resultsEl) return;
|
|
|
|
resultsEl.innerHTML = '<div class="travel-loading"><i class="fas fa-spinner fa-spin"></i> Calcul de l\'itinéraire…</div>';
|
|
|
|
const startSector = cleanSectorName(this._fromWorld.sector);
|
|
const endSector = cleanSectorName(this._toWorld.sector);
|
|
const startLoc = `${startSector} ${this._fromWorld.hex}`;
|
|
const endLoc = `${endSector} ${this._toWorld.hex}`;
|
|
|
|
try {
|
|
const resp = await fetch(
|
|
`https://travellermap.com/api/route?start=${encodeURIComponent(startLoc)}&end=${encodeURIComponent(endLoc)}&jump=${jump}`
|
|
);
|
|
|
|
if (!resp.ok) {
|
|
if (resp.status === 404) {
|
|
const text = await resp.text().catch(() => 'Aucun itinéraire trouvé');
|
|
resultsEl.innerHTML = `<div class="travel-error">${this._escapeHtml(text)}</div>`;
|
|
} else {
|
|
resultsEl.innerHTML = `<div class="travel-error">Erreur API (${resp.status})</div>`;
|
|
}
|
|
return;
|
|
}
|
|
|
|
const data = await resp.json();
|
|
if (!Array.isArray(data) || data.length < 2) {
|
|
resultsEl.innerHTML = '<div class="travel-error">Aucun itinéraire trouvé</div>';
|
|
return;
|
|
}
|
|
this._displayRoute(data, resultsEl);
|
|
} catch (err) {
|
|
console.error('TravelDialog | Erreur:', err);
|
|
resultsEl.innerHTML = `<div class="travel-error">Erreur : ${this._escapeHtml(err.message)}</div>`;
|
|
}
|
|
}
|
|
|
|
_displayRoute(worlds, resultsEl) {
|
|
this._lastWorlds = worlds;
|
|
const segments = [];
|
|
let totalParsecs = 0;
|
|
for (let i = 0; i < worlds.length - 1; i++) {
|
|
const a = worlds[i];
|
|
const b = worlds[i + 1];
|
|
const fromC = worldCoord(a.SectorX, a.SectorY, a.HexX, a.HexY);
|
|
const toC = worldCoord(b.SectorX, b.SectorY, b.HexX, b.HexY);
|
|
const dist = calcParsecs(fromC, toC);
|
|
totalParsecs += dist;
|
|
segments.push({ from: a, to: b, dist });
|
|
}
|
|
this._lastSegments = segments;
|
|
|
|
let html = `<div class="travel-route-summary">
|
|
<i class="fas fa-route"></i>
|
|
<strong>${segments.length}</strong> saut${segments.length > 1 ? 's' : ''}
|
|
· <strong>${totalParsecs}</strong> parsecs
|
|
</div>`;
|
|
|
|
html += `<div class="travel-route-duration">
|
|
<i class="fas fa-clock"></i> Durée estimée : <strong>${segments.length}</strong> semaine${segments.length > 1 ? 's' : ''}
|
|
</div>`;
|
|
|
|
html += '<ol class="travel-jump-list">';
|
|
segments.forEach((seg) => {
|
|
const f = seg.from;
|
|
const t = seg.to;
|
|
html += `<li>
|
|
<div class="jump-segment">
|
|
<div class="jump-world jump-from">
|
|
<span class="jump-world-name">${f.Name || '?'}</span>
|
|
<span class="jump-world-detail">${f.Sector} ${f.Hex || ''}</span>
|
|
</div>
|
|
<div class="jump-arrow"><i class="fas fa-long-arrow-alt-right"></i></div>
|
|
<div class="jump-world jump-to">
|
|
<span class="jump-world-name">${t.Name || '?'}</span>
|
|
<span class="jump-world-detail">${t.Sector} ${t.Hex || ''}</span>
|
|
</div>
|
|
<div class="jump-distance">Saut-${seg.dist}</div>
|
|
</div>
|
|
</li>`;
|
|
});
|
|
html += '</ol>';
|
|
|
|
resultsEl.innerHTML = html;
|
|
|
|
const journalBtn = this.element?.querySelector('.travel-journal-actions');
|
|
if (journalBtn) journalBtn.style.display = 'block';
|
|
}
|
|
|
|
async _createJournal() {
|
|
const worlds = this._lastWorlds;
|
|
const segments = this._lastSegments;
|
|
if (!worlds?.length || !segments?.length) {
|
|
ui.notifications.warn('Calculez d\'abord un itinéraire');
|
|
return;
|
|
}
|
|
|
|
const from = worlds[0];
|
|
const to = worlds[worlds.length - 1];
|
|
const totalParsecs = segments.reduce((s, seg) => s + seg.dist, 0);
|
|
const totalJumps = segments.length;
|
|
const jumpRating = this.element?.querySelector('[name="travel-jump"]')?.value || '?';
|
|
|
|
const lines = [];
|
|
lines.push(`<h2>Journal de voyage</h2>`);
|
|
lines.push(`<p><strong>Départ :</strong> ${this._worldLink(from)}</p>`);
|
|
lines.push(`<p><strong>Destination :</strong> ${this._worldLink(to)}</p>`);
|
|
lines.push(`<p><strong>Moteur :</strong> J-${jumpRating}</p>`);
|
|
lines.push(`<hr>`);
|
|
|
|
lines.push(`<h3>Itinéraire (${totalJumps} saut${totalJumps > 1 ? 's' : ''}, ${totalParsecs} pc)</h3>`);
|
|
lines.push(`<table><thead><tr><th>#</th><th>Départ</th><th>→</th><th>Arrivée</th><th>Distance</th></tr></thead><tbody>`);
|
|
segments.forEach((seg, i) => {
|
|
const f = seg.from;
|
|
const t = seg.to;
|
|
lines.push(`<tr>
|
|
<td>${i + 1}</td>
|
|
<td>${this._worldLink(f)}</td>
|
|
<td>→</td>
|
|
<td>${this._worldLink(t)}</td>
|
|
<td>${seg.dist} pc</td>
|
|
</tr>`);
|
|
});
|
|
lines.push(`</tbody></table>`);
|
|
|
|
lines.push(`<hr>`);
|
|
lines.push(`<h3>Mondes visités</h3>`);
|
|
lines.push(`<ul>`);
|
|
const seen = new Set();
|
|
for (const w of worlds) {
|
|
const key = `${w.Sector}|${w.Hex}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
lines.push(`<li>${this._worldLink(w)}${w.UWP ? ` — UWP: ${w.UWP}` : ''}</li>`);
|
|
}
|
|
lines.push(`</ul>`);
|
|
|
|
const content = lines.join('\n');
|
|
|
|
try {
|
|
const journal = await JournalEntry.create({
|
|
name: `Voyage : ${from.Name || '?'} → ${to.Name || '?'}`,
|
|
pages: [{
|
|
name: 'Itinéraire',
|
|
type: 'text',
|
|
text: { content, format: 2 },
|
|
}],
|
|
});
|
|
|
|
if (journal) {
|
|
ui.notifications.info(`Journal créé : ${journal.name}`);
|
|
journal.sheet?.render(true);
|
|
}
|
|
} catch (err) {
|
|
console.error('TravelDialog | Erreur création journal:', err);
|
|
ui.notifications.error('Erreur lors de la création du journal');
|
|
}
|
|
}
|
|
|
|
_worldLink(w) {
|
|
const name = w.Name || '?';
|
|
const sector = w.Sector || '';
|
|
const hex = w.Hex || '';
|
|
return `<a class="mgt2-world-link" data-sector="${this._escapeAttr(sector)}" data-hex="${this._escapeAttr(hex)}">${name}</a> <em>(${sector} ${hex})</em>`;
|
|
}
|
|
|
|
_escapeAttr(str) {
|
|
if (!str) return '';
|
|
return str.replace(/"/g, '"').replace(/&/g, '&');
|
|
}
|
|
|
|
_escapeHtml(str) {
|
|
if (!str) return '';
|
|
const d = document.createElement('div');
|
|
d.textContent = str;
|
|
return d.innerHTML;
|
|
}
|
|
}
|