MAp management and helpers
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user