Merge pull request #26 from carlomorgenstern/master

rework/enhancement
This commit is contained in:
ArcaneDisgea 2021-10-28 19:45:10 -04:00 committed by GitHub
commit aff43cef4c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
95 changed files with 1072 additions and 1220 deletions

BIN
chara.png

Binary file not shown.

Before

Width:  |  Height:  |  Size: 821 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 606 KiB

View file

@ -1,28 +1,22 @@
const fetch = require("node-fetch"); const fetch = require('node-fetch');
const path = require("path"); const path = require('path');
const { createCanvas, loadImage, registerFont } = require("canvas"); const { createCanvas, loadImage, registerFont } = require('canvas');
const createilvlfilter = require('./createilvlfilter')
let ilvlarray; const createIlvlFilter = require('./create-ilvl-filter');
console.log('Generating ilvl filter')
createilvlfilter().then((ilvlfilter) => {
ilvlarray = ilvlfilter;
console.log("ilvl filter generated!")
})
function absolute(relativePath) { function absolute(relativePath) {
return path.join(__dirname, relativePath); return path.join(__dirname, relativePath);
} }
registerFont(absolute('SourceSansPro-Regular.ttf'), { family: 'Source Sans Pro', style: 'Regular' }); registerFont(absolute('./resources/SourceSansPro-Regular.ttf'), { family: 'Source Sans Pro', style: 'Regular' });
registerFont(absolute('SourceSansPro-SemiBold.ttf'), { family: 'Source Sans Pro', style: 'SemiBold' }); registerFont(absolute('./resources/SourceSansPro-SemiBold.ttf'), { family: 'Source Sans Pro', style: 'SemiBold' });
const primary = "rgba(178, 214, 249, 1)"; const primary = 'rgba(178, 214, 249, 1)';
const white = "rgba(255, 255, 255,1)"; const white = 'rgba(255, 255, 255,1)';
const grey = "#868686"; const grey = '#868686';
const black = "rgba(0,0,0,0.5)"; const black = 'rgba(0,0,0,0.5)';
const copyright = '"11px "Source Sans Pro"'; const copyright = '11px "Source Sans Pro"';
const small = '"18px "Source Sans Pro"'; const small = '18px "Source Sans Pro"';
const med = '30px "Source Sans Pro"'; const med = '30px "Source Sans Pro"';
const smed = '25px "Source Sans Pro"'; const smed = '25px "Source Sans Pro"';
const large = '45px "Source Sans Pro SemiBold"'; const large = '45px "Source Sans Pro SemiBold"';
@ -87,13 +81,43 @@ const infoTextSmallStartY = rectStartRow3Y + infoTextStartSpacing;
const infoTextBigStartY = infoTextSmallStartY + 25; const infoTextBigStartY = infoTextSmallStartY + 25;
const infoTextSpacing = 50; const infoTextSpacing = 50;
const xivApiSupportedLanguages = ['en', 'ja', 'de', 'fr'];
const languageStrings = {
en: {
raceAndClan: 'Race & Clan',
guardian: 'Guardian',
grandCompany: 'Grand Company',
freeCompany: 'Free Company',
elementalLevel: 'Elemental Level',
eurekaLevel: 'Level',
resistanceRank: 'Resistance Rank',
bozjaRank: 'Rank',
mounts: 'Mounts',
minions: 'Minions',
},
de: {
raceAndClan: 'Volk & Stamm',
guardian: 'Schutzgott',
grandCompany: 'Staatliche Gesellschaft',
freeCompany: 'Freie Gesellschaft',
elementalLevel: 'Das Verbotene Land Eureka',
eurekaLevel: 'Elementarstufe',
resistanceRank: 'Bozja-Südfront',
bozjaRank: 'Widerstandsstufe',
mounts: 'Reittiere',
minions: 'Begleiter',
},
};
class CardCreator { class CardCreator {
/** /**
* Creates a new card creator. * Creates a new card creator.
* @constructor * @constructor
* @param {string} [xivApiKey] The API key for the XIV API to be used in all requests.
*/ */
constructor() { constructor(xivApiKey = undefined) {
this.isInit = false; this.xivApiKey = typeof xivApiKey === 'string' && xivApiKey !== '' ? xivApiKey : undefined;
this.initPromise = null;
} }
/** /**
@ -120,541 +144,459 @@ class CardCreator {
* @returns {Promise} A promise representing the initialization state of this generator. * @returns {Promise} A promise representing the initialization state of this generator.
*/ */
async ensureInit() { async ensureInit() {
if (this.isInit) { if (this.initPromise == null) this.initPromise = this.init();
return;
}
await this.init(); await this.initPromise;
this.isInit = true;
} }
async init() { async init() {
var d = new Date(); const commonImagesPromise = Promise.all([
this.copyrightYear = d.getFullYear(); loadImage(absolute('./resources/background.png')),
loadImage(absolute('./resources/minion.png')),
loadImage(absolute('./resources/mount.png')),
loadImage(absolute('./resources/ilvl-icon.png')),
loadImage(absolute('./resources/shadow.png')),
]).then(([background, minion, mount, ilvl, shadow]) => {
this.images = {
background, minion, mount, ilvl, shadow,
};
});
this.bgImage = await loadImage(absolute("./chara_top.png")); const classJobs = [
'alchemist', 'armorer', 'blacksmith', 'carpenter', 'culinarian', 'goldsmith', 'leatherworker', 'weaver',
'botanist', 'fisher', 'miner',
'gladiator', 'paladin', 'marauder', 'warrior', 'darkknight', 'gunbreaker',
'conjurer', 'whitemage', 'scholar', 'astrologian',
'archer', 'bard', 'machinist', 'dancer',
'lancer', 'dragoon', 'pugilist', 'monk', 'rogue', 'ninja', 'samurai',
'thaumaturge', 'blackmage', 'arcanist', 'summoner', 'redmage',
'bluemage',
];
this.imgMinion = await loadImage(absolute("./minion.png")); const classJobIconsPromise = Promise.all(
this.imgMount = await loadImage(absolute("./mount.png")); classJobs.map(name => loadImage(absolute(`./resources/class-jobs-icons/${name}.png`)))
this.imgIlvl = await loadImage(absolute("./ilvl_n.png")); ).then(images => {
this.imgShadow = await loadImage(absolute("./shadow.png")); this.cjIcons = {};
images.forEach((image, index) => this.cjIcons[classJobs[index]] = image);
});
this.imgAlchemist = await loadImage(absolute("./cj/1/alchemist.png")); const jobBackgroundsPromise = Promise.all(
this.imgArmorer = await loadImage(absolute("./cj/1/armorer.png")); Array.from({ length: 38 }, (_, index) => loadImage(absolute(`./resources/class-jobs-backgrounds/${index + 1}.png`)))
this.imgBlacksmith = await loadImage(absolute("./cj/1/blacksmith.png")); ).then(images => this.jobBackgrounds = images);
this.imgCarpenter = await loadImage(absolute("./cj/1/carpenter.png"));
this.imgCulinarian = await loadImage(absolute("./cj/1/culinarian.png"));
this.imgGoldsmith = await loadImage(absolute("./cj/1/goldsmith.png"));
this.imgLeatherworker = await loadImage(absolute("./cj/1/leatherworker.png"));
this.imgWeaver = await loadImage(absolute("./cj/1/weaver.png"));
this.imgBotanist = await loadImage(absolute("./cj/1/botanist.png")); const ilevelFilterPromise = createIlvlFilter(this.xivApiKey).then(filterIds => this.ilvlFilterIds = filterIds);
this.imgFisher = await loadImage(absolute("./cj/1/fisher.png"));
this.imgMiner = await loadImage(absolute("./cj/1/miner.png"));
this.imgGladiator = await loadImage(absolute("./cj/1/gladiator.png")); const minionCountPromise = fetch(`https://ffxivcollect.com/api/minions/`)
this.imgPaladin = await loadImage(absolute("./cj/1/paladin.png")); .then(response => response.json())
this.imgMarauder = await loadImage(absolute("./cj/1/marauder.png")); .then(data => this.minionCount = data.count);
this.imgWarrior = await loadImage(absolute("./cj/1/warrior.png"));
this.imgDarkKnight = await loadImage(absolute("./cj/1/darkknight.png"));
this.imgGunbreaker = await loadImage(absolute("./cj/1/gunbreaker.png"));
this.imgConjurer = await loadImage(absolute("./cj/1/conjurer.png")); const mountCountPromise = fetch(`https://ffxivcollect.com/api/mounts/`)
this.imgWhitemage = await loadImage(absolute("./cj/1/whitemage.png")); .then(response => response.json())
this.imgScholar = await loadImage(absolute("./cj/1/scholar.png")); .then(data => this.mountCount = data.count);
this.imgAstrologian = await loadImage(absolute("./cj/1/astrologian.png"));
this.imgArcher = await loadImage(absolute("./cj/1/archer.png")); await Promise.all([
this.imgBard = await loadImage(absolute("./cj/1/bard.png")); commonImagesPromise,
this.imgMachinist = await loadImage(absolute("./cj/1/machinist.png")); classJobIconsPromise,
this.imgDancer = await loadImage(absolute("./cj/1/dancer.png")); jobBackgroundsPromise,
ilevelFilterPromise,
this.imgLancer = await loadImage(absolute("./cj/1/lancer.png")); minionCountPromise,
this.imgDragoon = await loadImage(absolute("./cj/1/dragoon.png")); mountCountPromise,
this.imgPugilist = await loadImage(absolute("./cj/1/pugilist.png")); ]);
this.imgMonk = await loadImage(absolute("./cj/1/monk.png"));
this.imgRogue = await loadImage(absolute("./cj/1/rogue.png"));
this.imgNinja = await loadImage(absolute("./cj/1/ninja.png"));
this.imgSamurai = await loadImage(absolute("./cj/1/samurai.png"));
this.imgThaumaturge = await loadImage(absolute("./cj/1/thaumaturge.png"));
this.imgBlackmage = await loadImage(absolute("./cj/1/blackmage.png"));
this.imgArcanist = await loadImage(absolute("./cj/1/arcanist.png"));
this.imgSummoner = await loadImage(absolute("./cj/1/summoner.png"));
this.imgRedmage = await loadImage(absolute("./cj/1/redmage.png"));
this.imgBluemage = await loadImage(absolute('./cj/1/bluemage.png'));
this.imgJobBg = {};
for (var i = 1; i <= 38; i++) {
this.imgJobBg[i] = await loadImage(absolute(`./cj/bg/${i}.png`));
} }
await this.countMountsMinions(); async createCrest(crests) {
} if (!Array.isArray(crests) || crests.length == 0) return null;
async countMountsMinions() {
var response = await fetch(`https://ffxivcollect.com/api/minions/`);
var data = await response.json();
this.countMinion = data.count;
var response = await fetch(`https://ffxivcollect.com/api/mounts/`);
var data = await response.json();
this.countMount = data.count;
console.log(`Refreshed counts: ${this.countMinion} - ${this.countMount}`);
}
async createCrest(crestAry) {
const canvas = createCanvas(128, 128); const canvas = createCanvas(128, 128);
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext('2d');
if (crestAry.length == 0) const crestLayers = await Promise.all(crests.map(crest => loadImage(crest)));
return null;
for (var i = 0; i < crestAry.length; i++) { for (const layer of crestLayers) {
var crestLayer = await loadImage(crestAry[i]); ctx.drawImage(layer, 0, 0, 128, 128);
ctx.drawImage(crestLayer, 0, 0, 128, 128);
} }
var imgd = ctx.getImageData(0, 0, 128, 128), const imageData = ctx.getImageData(0, 0, 128, 128);
pix = imgd.data, const pixelData = imageData.data;
newColor = { r: 0, g: 0, b: 0, a: 0 };
for (var i = 0, n = pix.length; i < n; i += 4) { // Iterate over all pixels, where one consists of 4 numbers: r, g, b and a
var r = pix[i], for (let index = 0; index < pixelData.length; index += 4) {
g = pix[i + 1], const [r, g, b] = pixelData.slice(index, index + 3);
b = pix[i + 2];
// If its white then change it // If the pixel is a special grey, change it to be transparent (a = 0)
if (r == 64 && g == 64 && b == 64) { if (r == 64 && g == 64 && b == 64) {
// Change the white to whatever. pixelData[index] = 0;
pix[i] = newColor.r; pixelData[index + 1] = 0;
pix[i + 1] = newColor.g; pixelData[index + 2] = 0;
pix[i + 2] = newColor.b; pixelData[index + 3] = 0;
pix[i + 3] = newColor.a;
} }
} }
ctx.putImageData(imgd, 0, 0); ctx.putImageData(imageData, 0, 0);
return canvas; return canvas;
} }
getItemLevel(gearset) { getItemLevel(gearset) {
var ilvl = 0; let itemLevelSum = 0;
var cnt = 0;
var mainHandLvl = 0;
var hasOffHand = false;
console.log(ilvlarray) for (const [key, piece] of Object.entries(gearset)) {
for (var key in gearset) {
var piece = gearset[key];
if (key == 'SoulCrystal') // Exclude SoulCrystal from item level sum
continue; if (key !== 'SoulCrystal') {
if (key == 'MainHand') // If this item is a special one, increase the total item level by only 1
mainHandLvl = piece.Item.LevelItem; if (this.ilvlFilterIds.includes(piece.Item.ID) == true) {
itemLevelSum += 1;
if (key == 'OffHand')
hasOffHand = true;
if(ilvlarray.includes(piece.Item.ID) == true) {
ilvl += 1;
} else { } else {
ilvl += piece.Item.LevelItem; itemLevelSum += piece.Item.LevelItem;
}
}
} }
cnt++; // If there is no OffHand, the MainHand item level counts twice
if (gearset.Offhand != null && typeof gearset.MainHand != 'number') {
const piece = gearset.MainHand;
// If this item is a special one, increase the total item level by only 1
if (this.ilvlFilterIds.includes(piece.Item.ID) == true) {
itemLevelSum += 1;
} else {
itemLevelSum += piece.Item.LevelItem;
}
} }
if (!hasOffHand) { // Average item level computation is always for 13 items
ilvl += mainHandLvl; // Job stones are ignored
cnt++; return this.pad(Math.floor(itemLevelSum / 13), 4);
} }
if (cnt == 0) pad(number, size) {
return 0; const string = String(number);
const paddingCount = Math.max(size - string.length, 0);
// ilvl division is always out of 13 items return '0'.repeat(paddingCount) + string;
// mainhand counts twice if there's no offhand
// job stones are ignored
return this.pad(Math.floor(ilvl / 13), 4);
} }
pad(num, size) { classOrJobIcon(classJob, unlockId, className, jobName) {
num = num.toString(); if (classJob?.UnlockedState?.ID === unlockId) return this.cjIcons[jobName];
while (num.length < size) num = "0" + num; else return this.cjIcons[className];
return num;
} }
/** /**
* Creates a character card for a character. * Creates a character card for a character.
* @param {number | string} charaId The Lodestone ID of the character to generate a card for. * @param {number | string} characterId The Lodestone ID of the character to generate a card for.
* @param {string | Buffer | null | undefined} customImage Optional parameter providing a custom * @param {string | Buffer | null | undefined} customImage Optional parameter providing a custom
* image to be drawn between the background of the character card and the black information boxes. * image to be drawn between the background of the character card and the black information boxes.
* The image should be the same resolution as the default image. The default image size can be * The image should be the same resolution as the default image. The default image size can be
* retrieved with {@link CardCreator#canvasSize}. May be a URL, `data: `URI, a local file path, * retrieved with {@link CardCreator#canvasSize}. May be a URL, `data: `URI, a local file path,
* or a Buffer instance. * or a Buffer instance.
* @param {string} [language] The language that the cards should be in use for the request
* @example * @example
* const fs = require("fs"); * const fs = require('fs');
* *
* const card = new CardCreator(); * const card = new CardCreator();
* const lodestoneId = "13821878"; * const lodestoneId = '13821878';
* *
* await card.ensureInit(); * await card.ensureInit();
* const png = await card.createCard(lodestoneId); * const png = await card.createCard(lodestoneId);
* *
* fs.writeFile("./test.png", png, err => { * fs.writeFile('./test.png', png, err => {
* if (err) console.error(err); * if (err) console.error(err);
* }); * });
* @returns {Promise<Buffer>} A promise representating the construction of the card's image data. * @returns {Promise<Buffer>} A promise representating the construction of the card's image data.
*/ */
async createCard(charaId, customImage) { async createCard(characterId, customImage, language = 'en') {
const characterInfoUrl = `https://xivapi.com/character/${charaId}?extended=1&data=FC,mimo`; const supportedLanguage = xivApiSupportedLanguages.includes(language) ? language : 'en';
let response = await fetch(characterInfoUrl); const strings = Object.keys(languageStrings).includes(supportedLanguage) ? languageStrings[supportedLanguage] : languageStrings.en;
if (!response.ok) {
// Request all API data as early as possible
const neededFields = [
'Character.ActiveClassJob.UnlockedState.ID', 'Character.ClassJobs.*.Level', 'Character.ClassJobs.*.UnlockedState.ID', 'Character.ClassJobsBozjan.Level', 'Character.ClassJobsElemental.Level',
'Character.DC', 'Character.FreeCompanyName', 'Character.GearSet.Gear', 'Character.GrandCompany.Company.Name', 'Character.GrandCompany.Rank.Icon', 'Character.GuardianDeity.Name',
'Character.GuardianDeity.Icon', 'Character.Name', 'Character.Portrait', 'Character.Race.Name', 'Character.Tribe.Name', 'Character.Server', 'Character.Title.Name',
'FreeCompany.Crest', 'FreeCompany.Tag', 'Minions.*.dummy', 'Mounts.*.dummy',
];
const characterInfoUrl = new URL(`https://xivapi.com/character/${encodeURIComponent(characterId)}`)
characterInfoUrl.searchParams.set('language', supportedLanguage);
characterInfoUrl.searchParams.set('extended', '1');
characterInfoUrl.searchParams.set('data', 'FC,MIMO');
characterInfoUrl.searchParams.set('columns', neededFields.join(','));
if (typeof this.xivApiKey === 'string' && this.xivApiKey !== '') url.searchParams.set('private_key', this.xivApiKey);
const dataPromise = fetch(characterInfoUrl)
// Retry once if the request fails // Retry once if the request fails
response = await fetch(characterInfoUrl); .then(response => response.ok ? response : fetch(characterInfoUrl))
} .then(response => response.json());
const data = await response.json(); const customImagePromise = customImage != null ? loadImage(customImage) : Promise.resolve();
const portraitPromise = dataPromise.then(data => loadImage(data.Character.Portrait));
const deityPromise = dataPromise.then(data => loadImage(`https://xivapi.com/${data.Character.GuardianDeity.Icon}`));
const gcRankPromise = dataPromise.then(data => data.Character.GrandCompany.Company != null ? loadImage(`https://xivapi.com/${data.Character.GrandCompany.Rank.Icon}`) : null);
const fcCrestPromise = dataPromise.then(data => data.Character.FreeCompanyName != null ? this.createCrest(data.FreeCompany.Crest) : null);
// Build canvas and only await data, when actually needed
const canvasSize = this.canvasSize; const canvasSize = this.canvasSize;
const canvas = createCanvas(canvasSize.width, canvasSize.height); const canvas = createCanvas(canvasSize.width, canvasSize.height);
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext('2d');
ctx.save();
const portrait = await loadImage(data.Character.Portrait); // Draw background
ctx.drawImage(this.images.background, 0, 0, canvasSize.width, canvasSize.height + 2);
ctx.drawImage(this.bgImage, 0, 0, canvasSize.width, canvasSize.height + 2); // Draw custom background image
const customLoadedImage = await customImagePromise;
ctx.drawImage(portrait, 0, 120, 441, 600); if (customLoadedImage != null) {
ctx.drawImage(customLoadedImage, 0, 0, canvasSize.width, canvasSize.height);
if (customImage != null) {
const bg = await loadImage(customImage);
ctx.drawImage(bg, 0, 0, canvasSize.width, canvasSize.height);
} }
ctx.strokeStyle = white; // Draw dark background boxes
ctx.fillStyle = black; ctx.fillStyle = black;
ctx.beginPath(); ctx.fillRect(25, 10, 840, 100); // Name, Title, Server
// Name, Title, Server Rect ctx.fillRect(rectStartX, rectStartRow2Y, rectHalfWidth, rectHeightRow2); // Mounts
ctx.fillRect(25, 10, 840, 100); ctx.fillRect(rectStartXHalf, rectStartRow2Y, rectHalfWidth, rectHeightRow2); // Minions
ctx.fillRect(rectStartX, rectStartRow3Y, rectFullWidth, rectHeightRow3); // Character information
ctx.fillRect(rectStartX, rectStartRow4Y, rectFullWidth, rectHeightRow4); // Eureka & Bozja
ctx.fillRect(rectStartX, rectStartRow5Y, rectFullWidth, rectHeightRow5); // Classes & Jobs
ctx.restore(); ctx.save();
// BLU returns a null UnlockedState.ID so we can't use it to pick the job image. // Draw non data dependent text
if (data.Character.ActiveClassJob.UnlockedState.ID != null) { ctx.textAlign = 'left';
ctx.drawImage(this.imgJobBg[data.Character.ActiveClassJob.UnlockedState.ID], 450, 4, rectFullWidth, 110); ctx.font = small;
} else { ctx.fillStyle = primary;
ctx.drawImage(this.imgJobBg[36], 450, 4, rectFullWidth, 110); ctx.fillText(strings.raceAndClan, 480, infoTextSmallStartY); // Race & Clan
ctx.fillText(strings.guardian, 480, infoTextSmallStartY + infoTextSpacing); // Guardian
ctx.fillText(strings.elementalLevel, 480, 425); // Elemental level
ctx.fillText(strings.resistanceRank, 480, 475); // Resistance rank
ctx.font = copyright;
ctx.fillStyle = black;
ctx.fillText(`© 2010 - ${new Date().getFullYear()} SQUARE ENIX CO., LTD. All Rights Reserved`, rectStartX, 720 - 5); // Copyright
ctx.restore(); ctx.save();
// Draw non data dependent images
ctx.drawImage(this.images.shadow, 441 - 143, 110, 170, 90); // Item level shadow
ctx.drawImage(this.images.ilvl, 441 - 92, 132, 24, 27); // Item level icon
ctx.drawImage(this.images.mount, 620, iconMountMinionY, 32, 32); // Mount icon
ctx.drawImage(this.images.minion, 834, iconMountMinionY, 19, 32); // Minion icon
// Draw non data dependent job icons
{
ctx.drawImage(this.cjIcons.darkknight, 540, jobsRowIcon1Y, 30, 30); // Darkknight
ctx.drawImage(this.cjIcons.gunbreaker, 570, jobsRowIcon1Y, 30, 30); // Gunbreaker
ctx.drawImage(this.cjIcons.scholar, 660, jobsRowIcon1Y, 30, 30); // Scholar
ctx.drawImage(this.cjIcons.astrologian, 690, jobsRowIcon1Y, 30, 30); // Astrologian
ctx.drawImage(this.cjIcons.machinist, 780, jobsRowIcon1Y, 30, 30); // Machinist
ctx.drawImage(this.cjIcons.dancer, 810, jobsRowIcon1Y, 30, 30); // Dancer
ctx.drawImage(this.cjIcons.samurai, 570, jobsRowIcon2Y, 30, 30); // Samurai
ctx.drawImage(this.cjIcons.redmage, 690, jobsRowIcon2Y, 30, 30); // Redmage
ctx.drawImage(this.cjIcons.bluemage, 780, jobsRowIcon2Y, 33, 33); // Bluemage
ctx.drawImage(this.cjIcons.carpenter, 480, jobsRowIcon3Y, 30, 30); // Carpenter
ctx.drawImage(this.cjIcons.blacksmith, 510, jobsRowIcon3Y, 30, 30); // Blacksmith
ctx.drawImage(this.cjIcons.armorer, 540, jobsRowIcon3Y, 30, 30); // Armorer
ctx.drawImage(this.cjIcons.goldsmith, 570, jobsRowIcon3Y, 30, 30); // Goldsmith
ctx.drawImage(this.cjIcons.leatherworker, 600, jobsRowIcon3Y, 30, 30); // Leatherworker
ctx.drawImage(this.cjIcons.weaver, 630, jobsRowIcon3Y, 30, 30); // Weaver
ctx.drawImage(this.cjIcons.alchemist, 660, jobsRowIcon3Y, 30, 30); // Alchemist
ctx.drawImage(this.cjIcons.culinarian, 690, jobsRowIcon3Y, 30, 30); // Culinarian
ctx.drawImage(this.cjIcons.miner, 750, jobsRowIcon3Y, 30, 30); // Miner
ctx.drawImage(this.cjIcons.botanist, 780, jobsRowIcon3Y, 30, 30); // Botanist
ctx.drawImage(this.cjIcons.fisher, 810, jobsRowIcon3Y, 30, 30); // Fisher
} }
ctx.fillRect(rectStartX, rectStartRow2Y, rectHalfWidth, rectHeightRow2); // Draw info from character data
ctx.fillRect(rectStartXHalf, rectStartRow2Y, rectHalfWidth, rectHeightRow2); const { Character, FreeCompany, Mounts, Minions } = await dataPromise;
ctx.fillRect(rectStartX, rectStartRow3Y, rectFullWidth, rectHeightRow3); //info // Header
ctx.fillRect(rectStartX, rectStartRow4Y, rectFullWidth, rectHeightRow4); // bozja {
ctx.fillRect(rectStartX, rectStartRow5Y, rectFullWidth, rectHeightRow5); const activeClassJob = Character.ActiveClassJob.UnlockedState.ID ?? 36; // BLU returns a null UnlockedState.ID so we can't use it to pick the job image
ctx.stroke(); ctx.drawImage(this.jobBackgrounds[activeClassJob - 1], 450, 4, rectFullWidth, 110); // Current class/job background
ctx.textAlign = "center"; ctx.textAlign = 'center';
ctx.font = med; ctx.font = med;
ctx.fillStyle = primary; ctx.fillStyle = primary;
if (Character.Title.Name != null) ctx.fillText(Character.Title.Name, 450, 40); // Character title
if (data.Character.Title.Name !== undefined)
ctx.fillText(data.Character.Title.Name, 450, 40);
ctx.font = small; ctx.font = small;
ctx.fillText(`${data.Character.Server} (${data.Character.DC})`, 450, 100); ctx.fillText(`${Character.Server} (${Character.DC})`, 450, 100); // Character service & DC
// Race, Clan, Guardian, GC, FC Titles
ctx.font = small;
ctx.textAlign = "left";
ctx.fillText("Race & Clan", 480, infoTextSmallStartY);
ctx.fillText("Guardian", 480, infoTextSmallStartY + infoTextSpacing);
if (data.Character.GrandCompany.Company != null) {
ctx.fillText("Grand Company", 480, infoTextSmallStartY + infoTextSpacing * 2);
}
if (data.Character.FreeCompanyName != null) {
ctx.fillText("Free Company", 480, infoTextSmallStartY + infoTextSpacing * 3);
}
ctx.fillText("Elemental Level", 480, 425);
ctx.fillText("Resistance Rank", 480, 475);
ctx.fillStyle = grey;
ctx.font = smed;
var ilvl = this.getItemLevel(data.Character.GearSet.Gear);
ctx.drawImage(this.imgShadow, 441 - 143, 110, 170, 90);
ctx.drawImage(this.imgIlvl, 441 - 92, 132, 24, 27);
ctx.fillText(ilvl, 441 - 65, 155);
ctx.fillStyle = white;
ctx.font = large; ctx.font = large;
ctx.fillStyle = white;
ctx.textAlign = "center"; ctx.fillText(Character.Name, 450, 80); // Character name
// Chara Name ctx.restore(); ctx.save();
if (data.Character.Title === undefined || data.Character.Title.Name == null || data.Character.Title.Name == "") {
ctx.fillText(data.Character.Name, 450, 80);
} else {
ctx.fillText(data.Character.Name, 450, 80);
} }
// Race, Clan, Guardian, GC, FC Info
// Item level
{
ctx.font = smed; ctx.font = smed;
ctx.textAlign = "left";
ctx.fillText(`${data.Character.Race.Name}, ${data.Character.Tribe.Name}`, 480, infoTextBigStartY);
ctx.fillText(data.Character.GuardianDeity.Name, 480, infoTextBigStartY + infoTextSpacing);
var deityIcon = await loadImage('https://xivapi.com/' + data.Character.GuardianDeity.Icon);
ctx.drawImage(deityIcon, deityIconX, deityIconY, 28, 28);
if (data.Character.GrandCompany.Company != null) {
ctx.fillText(data.Character.GrandCompany.Company.Name, 480, infoTextBigStartY + infoTextSpacing * 2);
var gcRankIcon = await loadImage('https://xivapi.com/' + data.Character.GrandCompany.Rank.Icon);
ctx.drawImage(gcRankIcon, gcRankIconX, gcRankIconY, 40, 40);
}
if (data.Character.FreeCompanyName != null) {
var crestImage = await this.createCrest(data.FreeCompany.Crest);
if (crestImage !== null)
ctx.drawImage(crestImage, fcCrestX, fcCrestY, fcCrestScale, fcCrestScale);
const fcMeasure = ctx.measureText(data.Character.FreeCompanyName);
ctx.fillText(data.Character.FreeCompanyName, 480, infoTextBigStartY + infoTextSpacing * 3);
ctx.fillStyle = grey; ctx.fillStyle = grey;
ctx.font = small; ctx.fillText(this.getItemLevel(Character.GearSet.Gear), 441 - 65, 155); // Item level
ctx.fillText(`«${data.FreeCompany.Tag}»`, 480 + fcMeasure.width + 10, infoTextBigStartY + infoTextSpacing * 3); ctx.restore(); ctx.save();
} }
// Mounts & Minions
{
const mountsPercentage = Math.ceil(((Mounts.length ?? 0) / this.mountCount) * 100);
const minionsPercentage = Math.ceil(((Minions.length ?? 0) / this.minionCount) * 100);
ctx.font = smed; ctx.font = smed;
ctx.fillStyle = white; ctx.fillStyle = white;
const mountsMeasure = ctx.measureText(`${mountsPercentage}%`);
const minionsMeasure = ctx.measureText(`${minionsPercentage}%`);
ctx.fillText(`${mountsPercentage}%`, 480, textMountMinionY); // Mounts percentage
ctx.fillText(`${minionsPercentage}%`, 685, textMountMinionY); // Minions percentage
if (data.Character.ClassJobsElemental.Level != null) {
ctx.fillText(`Level ${data.Character.ClassJobsElemental.Level}`, 480, 450);
} else {
ctx.fillText(`Level 0`, 480, 450);
}
if (data.Character.ClassJobsBozjan.Level != null) {
ctx.fillText(`Rank ${data.Character.ClassJobsBozjan.Level}`, 480, 500);
} else {
ctx.fillText(`Rank 0`, 480, 500);
}
// Minion & Mount percentages
var mountsPct = '0';
if (data.Mounts !== null) {
mountsPct = Math.ceil((data.Mounts.length / this.countMount) * 100);
}
var minionsPct = '0';
if (data.Minions !== null) {
minionsPct = Math.ceil((data.Minions.length / this.countMinion) * 100);
}
const mountsMeasure = ctx.measureText(`${mountsPct}%`);
const minionsMeasure = ctx.measureText(`${minionsPct}%`);
ctx.fillText(`${mountsPct}%`, 480, textMountMinionY);
ctx.fillText(`${minionsPct}%`, 685, textMountMinionY);
ctx.fillStyle = grey;
ctx.font = small; ctx.font = small;
ctx.fillStyle = grey;
ctx.fillText(strings.mounts, 480 + mountsMeasure.width + 5, textMountMinionY); // Mounts
ctx.fillText(strings.minions, 685 + minionsMeasure.width + 5, textMountMinionY); // Minions
ctx.restore(); ctx.save();
}
ctx.fillText("Mounts", 480 + mountsMeasure.width + 5, textMountMinionY); // Character information
ctx.fillText("Minions", 685 + minionsMeasure.width + 5, textMountMinionY); {
ctx.font = smed;
ctx.fillStyle = white;
ctx.fillText(`${Character.Race.Name}, ${Character.Tribe.Name}`, 480, infoTextBigStartY); // Race & Clan
ctx.fillText(Character.GuardianDeity.Name, 480, infoTextBigStartY + infoTextSpacing); // Guardian
ctx.drawImage(this.imgMount, 620, iconMountMinionY, 32, 32); if (Character.GrandCompany.Company != null) {
ctx.drawImage(this.imgMinion, 834, iconMountMinionY, 19, 32); ctx.font = small;
ctx.fillStyle = primary;
ctx.fillText(strings.grandCompany, 480, infoTextSmallStartY + infoTextSpacing * 2); // Grand Company
ctx.font = smed;
ctx.fillStyle = white;
ctx.fillText(Character.GrandCompany.Company.Name.replace('[p]', ''), 480, infoTextBigStartY + infoTextSpacing * 2); // Grand Company name
}
if (Character.FreeCompanyName != null) {
ctx.font = small;
ctx.fillStyle = primary;
ctx.fillText(strings.freeCompany, 480, infoTextSmallStartY + infoTextSpacing * 3); // Free Company
ctx.font = smed;
ctx.fillStyle = white;
ctx.fillText(Character.FreeCompanyName, 480, infoTextBigStartY + infoTextSpacing * 3); // Free Company name
const nameMeasure = ctx.measureText(Character.FreeCompanyName);
ctx.font = small;
ctx.fillStyle = grey;
ctx.fillText(`«${FreeCompany.Tag}»`, 480 + nameMeasure.width + 10, infoTextBigStartY + infoTextSpacing * 3); // Free Company tag
}
ctx.restore(); ctx.save();
}
// Eureka & Bozja
{
ctx.font = smed;
ctx.fillStyle = white;
ctx.fillText(`${strings.eurekaLevel} ${Character.ClassJobsElemental.Level ?? 0}`, 480, 450); // Elemental level
ctx.fillText(`${strings.bozjaRank} ${Character.ClassJobsBozjan.Level ?? 0}`, 480, 500); // Resistance rank
ctx.restore(); ctx.save();
}
// Classes & Jobs - data dependant job or class icons
{
const { ClassJobs } = Character;
ctx.drawImage(this.classOrJobIcon(ClassJobs[0], 19, 'gladiator', 'paladin'), 480, jobsRowIcon1Y, 30, 30); // Gladiator/Paladin
ctx.drawImage(this.classOrJobIcon(ClassJobs[1], 21, 'marauder', 'warrior'), 510, jobsRowIcon1Y, 30, 30); // Marauder/Warrior
ctx.drawImage(this.classOrJobIcon(ClassJobs[8], 24, 'conjurer', 'whitemage'), 630, jobsRowIcon1Y, 30, 30); // Conjurer/Whitemage
ctx.drawImage(this.classOrJobIcon(ClassJobs[11], 23, 'archer', 'bard'), 750, jobsRowIcon1Y, 30, 30); // Archer/Bard
ctx.drawImage(this.classOrJobIcon(ClassJobs[5], 22, 'lancer', 'dragoon'), 480, jobsRowIcon2Y, 30, 30); // Lancer/Dragoon
ctx.drawImage(this.classOrJobIcon(ClassJobs[4], 20, 'pugilist', 'monk'), 510, jobsRowIcon2Y, 30, 30); // Monk/Pugilist
ctx.drawImage(this.classOrJobIcon(ClassJobs[6], 30, 'rogue', 'ninja'), 540, jobsRowIcon2Y, 30, 30); // Ninja/Rogue
ctx.drawImage(this.classOrJobIcon(ClassJobs[14], 25, 'thaumaturge', 'blackmage'), 630, jobsRowIcon2Y, 30, 30); // Thaumaturge/Blackmage
ctx.drawImage(this.classOrJobIcon(ClassJobs[15], 27, 'arcanist', 'summoner'), 660, jobsRowIcon2Y, 30, 30); // Summoner/Arcanist
}
// Classes & Jobs - levels
{
ctx.textAlign = 'center';
ctx.font = small;
ctx.fillStyle = white; ctx.fillStyle = white;
const { ClassJobs } = Character;
// Why are there so many fucking jobs in this game? // First row
// Crafting let rowTextX = jobsRowTextStartX;
ctx.textAlign = "center"; ctx.fillText(ClassJobs[0].Level, rowTextX, jobsRowText1Y); // Gladiator/Paladin
rowTextX += jobsRowTextSize;
ctx.fillText(ClassJobs[1].Level, rowTextX, jobsRowText1Y); // Marauder/Warrior
rowTextX += jobsRowTextSize;
ctx.fillText(ClassJobs[2].Level, rowTextX, jobsRowText1Y); // Darkknight
rowTextX += jobsRowTextSize;
ctx.fillText(ClassJobs[3].Level, rowTextX, jobsRowText1Y); // Gunbreaker
rowTextX += jobsRowTextSpacer;
ctx.fillText(ClassJobs[8].Level, rowTextX, jobsRowText1Y); // Conjurer/Whitemage
rowTextX += jobsRowTextSize;
ctx.fillText(ClassJobs[9].Level >= 30 ? ClassJobs[9].Level : '0', rowTextX, jobsRowText1Y); // Scholar
rowTextX += jobsRowTextSize;
ctx.fillText(ClassJobs[10].Level, rowTextX, jobsRowText1Y); // Astrologian
rowTextX += jobsRowTextSpacer;
ctx.fillText(ClassJobs[11].Level, rowTextX, jobsRowText1Y); // Archer/Bard
rowTextX += jobsRowTextSize;
ctx.fillText(ClassJobs[12].Level, rowTextX, jobsRowText1Y); // Machinist
rowTextX += jobsRowTextSize;
ctx.fillText(ClassJobs[13].Level, rowTextX, jobsRowText1Y); // Dancer
var cJobsRowTextX = jobsRowTextStartX; // Second row
ctx.drawImage(this.imgAlchemist, 480, jobsRowIcon3Y, 30, 30); rowTextX = jobsRowTextStartX;
ctx.fillText(data.Character.ClassJobs[24].Level, cJobsRowTextX, jobsRowText3Y); ctx.fillText(ClassJobs[5].Level, rowTextX, jobsRowText2Y); // Lancer/Dragoon
cJobsRowTextX += jobsRowTextSize; rowTextX += jobsRowTextSize;
ctx.fillText(ClassJobs[4].Level, rowTextX, jobsRowText2Y); // Monk/Pugilist
rowTextX += jobsRowTextSize;
ctx.fillText(ClassJobs[6].Level, rowTextX, jobsRowText2Y); // Ninja/Rogue
rowTextX += jobsRowTextSize;
ctx.fillText(ClassJobs[7].Level, rowTextX, jobsRowText2Y); // Samurai
rowTextX += jobsRowTextSpacer;
ctx.fillText(ClassJobs[14].Level, rowTextX, jobsRowText2Y); // Thaumaturge/Blackmage
rowTextX += jobsRowTextSize;
ctx.fillText(ClassJobs[15].Level, rowTextX, jobsRowText2Y); // Summoner/Arcanist
rowTextX += jobsRowTextSize;
ctx.fillText(ClassJobs[16].Level, rowTextX, jobsRowText2Y); // Redmage
rowTextX += jobsRowTextSize;
ctx.fillText(ClassJobs[17].Level, 796, jobsRowText2Y); // Bluemage
ctx.drawImage(this.imgArmorer, 510, jobsRowIcon3Y, 30, 30); // Third row
ctx.fillText(data.Character.ClassJobs[20].Level, cJobsRowTextX, jobsRowText3Y); rowTextX = jobsRowTextStartX;
cJobsRowTextX += jobsRowTextSize; ctx.fillText(ClassJobs[18].Level, rowTextX, jobsRowText3Y); // Carpenter
rowTextX += jobsRowTextSize;
ctx.drawImage(this.imgBlacksmith, 540, jobsRowIcon3Y, 30, 30); ctx.fillText(ClassJobs[19].Level, rowTextX, jobsRowText3Y); // Blacksmith
ctx.fillText(data.Character.ClassJobs[19].Level, cJobsRowTextX, jobsRowText3Y); rowTextX += jobsRowTextSize;
cJobsRowTextX += jobsRowTextSize; ctx.fillText(ClassJobs[20].Level, rowTextX, jobsRowText3Y); // Armorer
rowTextX += jobsRowTextSize;
ctx.drawImage(this.imgCarpenter, 570, jobsRowIcon3Y, 30, 30); ctx.fillText(ClassJobs[21].Level, rowTextX, jobsRowText3Y); // Goldsmith
ctx.fillText(data.Character.ClassJobs[18].Level, cJobsRowTextX, jobsRowText3Y); rowTextX += jobsRowTextSize;
cJobsRowTextX += jobsRowTextSize; ctx.fillText(ClassJobs[22].Level, rowTextX, jobsRowText3Y); // Leatherworker
rowTextX += jobsRowTextSize;
ctx.drawImage(this.imgCulinarian, 600, jobsRowIcon3Y, 30, 30); ctx.fillText(ClassJobs[23].Level, rowTextX, jobsRowText3Y); // Weaver
ctx.fillText(data.Character.ClassJobs[25].Level, cJobsRowTextX, jobsRowText3Y); rowTextX += jobsRowTextSize;
cJobsRowTextX += jobsRowTextSize; ctx.fillText(ClassJobs[24].Level, rowTextX, jobsRowText3Y); // Alchemist
rowTextX += jobsRowTextSize;
ctx.drawImage(this.imgGoldsmith, 630, jobsRowIcon3Y, 30, 30); ctx.fillText(ClassJobs[25].Level, rowTextX, jobsRowText3Y); // Culinarian
ctx.fillText(data.Character.ClassJobs[21].Level, cJobsRowTextX, jobsRowText3Y); rowTextX += jobsRowTextSpacer;
cJobsRowTextX += jobsRowTextSize; ctx.fillText(ClassJobs[26].Level, rowTextX, jobsRowText3Y); // Miner
rowTextX += jobsRowTextSize;
ctx.drawImage(this.imgLeatherworker, 660, jobsRowIcon3Y, 30, 30); ctx.fillText(ClassJobs[27].Level, rowTextX, jobsRowText3Y); // Botanist
ctx.fillText(data.Character.ClassJobs[22].Level, cJobsRowTextX, jobsRowText3Y); rowTextX += jobsRowTextSize;
cJobsRowTextX += jobsRowTextSize; ctx.fillText(ClassJobs[28].Level, rowTextX, jobsRowText3Y); // Fisher
ctx.drawImage(this.imgWeaver, 690, jobsRowIcon3Y, 30, 30);
ctx.fillText(data.Character.ClassJobs[23].Level, cJobsRowTextX, jobsRowText3Y);
cJobsRowTextX += jobsRowTextSpacer;
// Gathering
ctx.drawImage(this.imgBotanist, 750, jobsRowIcon3Y, 30, 30);
ctx.fillText(data.Character.ClassJobs[27].Level, cJobsRowTextX, jobsRowText3Y);
cJobsRowTextX += jobsRowTextSize;
ctx.drawImage(this.imgFisher, 780, jobsRowIcon3Y, 30, 30);
ctx.fillText(data.Character.ClassJobs[28].Level, cJobsRowTextX, jobsRowText3Y);
cJobsRowTextX += jobsRowTextSize;
ctx.drawImage(this.imgMiner, 810, jobsRowIcon3Y, 30, 30);
ctx.fillText(data.Character.ClassJobs[26].Level, cJobsRowTextX, jobsRowText3Y);
cJobsRowTextX += jobsRowTextSize;
// Tanks
cJobsRowTextX = jobsRowTextStartX;
if (data.Character.ClassJobs[0].UnlockedState.ID == 19) {
ctx.drawImage(this.imgPaladin, 480, jobsRowIcon1Y, 30, 30);
} else {
ctx.drawImage(this.imgGladiator, 480, jobsRowIcon1Y, 30, 30);
} }
ctx.fillText(data.Character.ClassJobs[0].Level, cJobsRowTextX, jobsRowText1Y);
cJobsRowTextX += jobsRowTextSize;
if (data.Character.ClassJobs[1].UnlockedState.ID == 21) { // Remaining asynchronous drawing
ctx.drawImage(this.imgWarrior, 510, jobsRowIcon1Y, 30, 30); {
} else { await Promise.all([
ctx.drawImage(this.imgMarauder, 510, jobsRowIcon1Y, 30, 30); portraitPromise.then(portrait => ctx.drawImage(portrait, 0, 120, 441, 600)),
deityPromise.then(deityIcon => ctx.drawImage(deityIcon, deityIconX, deityIconY, 28, 28)),
gcRankPromise.then(gcRankIcon => {
if (gcRankIcon != null) ctx.drawImage(gcRankIcon, gcRankIconX, gcRankIconY, 40, 40);
}),
fcCrestPromise.then(fcCrestIcon => {
if (fcCrestIcon != null) ctx.drawImage(fcCrestIcon, fcCrestX, fcCrestY, fcCrestScale, fcCrestScale);
}),
]);
} }
ctx.fillText(data.Character.ClassJobs[1].Level, cJobsRowTextX, jobsRowText1Y);
cJobsRowTextX += jobsRowTextSize;
ctx.drawImage(this.imgDarkKnight, 540, jobsRowIcon1Y, 30, 30);
ctx.fillText(data.Character.ClassJobs[2].Level, cJobsRowTextX, jobsRowText1Y);
cJobsRowTextX += jobsRowTextSize;
ctx.drawImage(this.imgGunbreaker, 570, jobsRowIcon1Y, 30, 30);
ctx.fillText(data.Character.ClassJobs[3].Level, cJobsRowTextX, jobsRowText1Y);
cJobsRowTextX += jobsRowTextSpacer;
// Healers
if (data.Character.ClassJobs[8].UnlockedState.ID == 24) {
ctx.drawImage(this.imgWhitemage, 630, jobsRowIcon1Y, 30, 30);
} else {
ctx.drawImage(this.imgConjurer, 630, jobsRowIcon1Y, 30, 30);
}
ctx.fillText(data.Character.ClassJobs[8].Level, cJobsRowTextX, jobsRowText1Y);
cJobsRowTextX += jobsRowTextSize;
ctx.drawImage(this.imgScholar, 660, jobsRowIcon1Y, 30, 30);
if (data.Character.ClassJobs[9].Level >= 30) {
ctx.fillText(data.Character.ClassJobs[9].Level, cJobsRowTextX, jobsRowText1Y);
} else {
ctx.fillText("0", cJobsRowTextX, jobsRowText1Y);
}
cJobsRowTextX += jobsRowTextSize;
ctx.drawImage(this.imgAstrologian, 690, jobsRowIcon1Y, 30, 30);
ctx.fillText(data.Character.ClassJobs[10].Level, cJobsRowTextX, jobsRowText1Y);
cJobsRowTextX += jobsRowTextSpacer;
// DPS
// Ranged
if (data.Character.ClassJobs[11].UnlockedState.ID == 23) {
ctx.drawImage(this.imgBard, 750, jobsRowIcon1Y, 30, 30);
} else {
ctx.drawImage(this.imgArcher, 750, jobsRowIcon1Y, 30, 30);
}
ctx.fillText(data.Character.ClassJobs[11].Level, cJobsRowTextX, jobsRowText1Y);
cJobsRowTextX += jobsRowTextSize;
ctx.drawImage(this.imgMachinist, 780, jobsRowIcon1Y, 30, 30);
ctx.fillText(data.Character.ClassJobs[12].Level, cJobsRowTextX, jobsRowText1Y);
cJobsRowTextX += jobsRowTextSize;
ctx.drawImage(this.imgDancer, 810, jobsRowIcon1Y, 30, 30);
ctx.fillText(data.Character.ClassJobs[13].Level, cJobsRowTextX, jobsRowText1Y);
cJobsRowTextX += jobsRowTextSize;
// Melee
cJobsRowTextX = jobsRowTextStartX;
if (data.Character.ClassJobs[5].UnlockedState.ID == 22) {
ctx.drawImage(this.imgDragoon, 480, jobsRowIcon2Y, 30, 30);
} else {
ctx.drawImage(this.imgLancer, 480, jobsRowIcon2Y, 30, 30);
}
ctx.fillText(data.Character.ClassJobs[5].Level, cJobsRowTextX, jobsRowText2Y);
cJobsRowTextX += jobsRowTextSize;
if (data.Character.ClassJobs[4].UnlockedState.ID == 20) {
ctx.drawImage(this.imgMonk, 510, jobsRowIcon2Y, 30, 30);
} else {
ctx.drawImage(this.imgPugilist, 510, jobsRowIcon2Y, 30, 30);
}
ctx.fillText(data.Character.ClassJobs[4].Level, cJobsRowTextX, jobsRowText2Y);
cJobsRowTextX += jobsRowTextSize;
if (data.Character.ClassJobs[6].UnlockedState.ID == 30) {
ctx.drawImage(this.imgNinja, 540, jobsRowIcon2Y, 30, 30);
} else {
ctx.drawImage(this.imgRogue, 540, jobsRowIcon2Y, 30, 30);
}
ctx.fillText(data.Character.ClassJobs[6].Level, cJobsRowTextX, jobsRowText2Y);
cJobsRowTextX += jobsRowTextSize;
ctx.drawImage(this.imgSamurai, 570, jobsRowIcon2Y, 30, 30);
ctx.fillText(data.Character.ClassJobs[7].Level, cJobsRowTextX, jobsRowText2Y);
cJobsRowTextX += jobsRowTextSpacer;
// Caster
if (data.Character.ClassJobs[14].UnlockedState.ID == 25) {
ctx.drawImage(this.imgBlackmage, 630, jobsRowIcon2Y, 30, 30);
} else {
ctx.drawImage(this.imgThaumaturge, 630, jobsRowIcon2Y, 30, 30);
}
ctx.fillText(data.Character.ClassJobs[14].Level, cJobsRowTextX, jobsRowText2Y);
cJobsRowTextX += jobsRowTextSize;
if (data.Character.ClassJobs[15].UnlockedState.ID == 27) {
ctx.drawImage(this.imgSummoner, 660, jobsRowIcon2Y, 30, 30);
} else {
ctx.drawImage(this.imgArcanist, 660, jobsRowIcon2Y, 30, 30);
}
ctx.fillText(data.Character.ClassJobs[15].Level, cJobsRowTextX, jobsRowText2Y);
cJobsRowTextX += jobsRowTextSize;
ctx.drawImage(this.imgRedmage, 690, jobsRowIcon2Y, 30, 30);
ctx.fillText(data.Character.ClassJobs[16].Level, cJobsRowTextX, jobsRowText2Y);
cJobsRowTextX += jobsRowTextSize;
// Limited
ctx.drawImage(this.imgBluemage, 780, jobsRowIcon2Y, 33, 33);
ctx.fillText(data.Character.ClassJobs[17].Level, 796, jobsRowText2Y);
ctx.textAlign = "left";
ctx.fillStyle = black;
ctx.font = copyright;
ctx.fillText(`© 2010 - ${this.copyrightYear} SQUARE ENIX CO., LTD. All Rights Reserved`, rectStartX, 720 - 5);
return canvas.toBuffer(); return canvas.toBuffer();
} }

33
create-ilvl-filter.js Normal file
View file

@ -0,0 +1,33 @@
const fetch = require('node-fetch');
const itemUICategory = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40,
41, 42, 43, 84, 87, 88, 89, 96, 97, 98, 99, 105, 106, 107,
];
// Returns a list of item IDs that, when equipped, increase the iLvl by 1
async function createIlvlFilter(xivApiKey = undefined) {
const itemIds = [];
// Fetch for each category one after another to prevent rate limiting
// This should only by needed on startup, so a longer caching time is ok
for (const category of itemUICategory) {
const url = new URL('https://xivapi.com/search');
url.searchParams.set('indexes', 'item');
url.searchParams.set('filters', `IsIndisposable=1,ItemUICategoryTargetID=${category}`);
if (typeof xivApiKey === 'string' && xivApiKey !== '') url.searchParams.set('private_key', xivApiKey);
const response = await fetch(url.toString());
const data = await response.json();
if (data != null && Array.isArray(data.Results)) {
for (const item of data.Results) {
itemIds.push(item.ID);
}
}
}
return itemIds;
}
module.exports = createIlvlFilter

View file

@ -1,27 +0,0 @@
const fetch = require("node-fetch");
const itemUICategory = [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 84, 87, 88, 89, 96, 97, 98, 99,
105, 106, 107,
];
async function createilvlfilter() {
let results = [];
for (const category of itemUICategory) {
let url = `https://xivapi.com/search?indexes=item&filters=IsIndisposable=1,ItemUICategoryTargetID=${category}`;
const response = await fetch(url);
const data = response.json();
data.then((payload) => {
if (payload.Pagination.Results === 0) {
return;
}
for (const item of payload.Results) {
let part = item.ID
results.push(part);
}
});
}
return results;
}
module.exports = createilvlfilter

View file

@ -1,6 +1,3 @@
const ghpages = require("gh-pages"); const ghpages = require('gh-pages');
ghpages.publish("docs/", (err) => {
if (err) { ghpages.publish('docs/', error => { if (error) console.error(error) });
console.error(err);
}
});

206
index.js
View file

@ -1,152 +1,150 @@
const fetch = require("node-fetch"); const cacheManager = require('cache-manager');
const express = require('express'); const express = require('express');
const app = express(); const fetch = require('node-fetch');
const port = process.env.PORT || 5000; const fsStore = require('cache-manager-fs-binary');
const rateLimit = require("express-rate-limit");
const { CardCreator } = require('./create-card'); const { CardCreator } = require('./create-card');
const creator = new CardCreator(); const port = process.env.PORT || 5000;
const xivApiKey = typeof process.env.XIV_API_KEY === 'string' && process.env.XIV_API_KEY !== '' ? process.env.XIV_API_KEY : undefined;
const supportedLanguages = ['en', 'ja', 'de', 'fr'];
// node cachemanager const app = express();
var cacheManager = require('cache-manager'); const creator = new CardCreator(xivApiKey);
// storage for the cachemanager
var fsStore = require('cache-manager-fs-binary'); // Initialize caching on disk
// initialize caching on disk const diskCache = cacheManager.caching({
var diskCache = cacheManager.caching({
store: fsStore, store: fsStore,
options: { options: {
reviveBuffers: true, reviveBuffers: true,
binaryAsStream: false, binaryAsStream: false,
ttl: 60 * 60 * 4 /* seconds */, ttl: 14400, // s = 4h
maxsize: 1000 * 1000 * 1000 /* max size in bytes on disk */, maxsize: 1000000000, // bytes = 1 GB
path: 'diskcache', path: 'diskcache',
preventfill: true preventfill: true,
} }
}); });
async function getCharIdByName(world, name, retries = 1) { // Rate limit all requests that result in XIV API calls
const limiter = rateLimit({
windowMs: 1000, // ms = 1s
max: 20, // default XIV API request limit
keyGenerator: () => 'global',
});
async function getCharacterIdByName(world, name, retries = 1) {
if (retries === -1) return undefined; if (retries === -1) return undefined;
const response = await fetch(`https://xivapi.com/character/search?name=${name}&server=${world}`); const searchUrl = new URL('https://xivapi.com/character/search');
searchUrl.searchParams.set('name', name)
searchUrl.searchParams.set('server', world)
if (xivApiKey != null) searchUrl.searchParams.set('private_key', xivApiKey)
const response = await fetch(searchUrl.toString());
const data = await response.json(); const data = await response.json();
if (data.Results[0] === undefined) if (data.Results[0] === undefined) return getCharacterIdByName(world, name, --retries);
return getCharIdByName(world, name, --retries);
return data.Results[0].ID; return data.Results[0].ID;
} }
app.get('/prepare/id/:charaId', async (req, res) => { async function cacheCreateCard(characterId, customImage, language) {
var cacheKey = `img:${req.params.charaId}`; const cacheKey = `img:${characterId}:${customImage}:${language}`;
var ttl = 60 * 60 * 4; // 4 hours
diskCache.wrap(cacheKey, return diskCache.wrap(cacheKey, async () => {
// called if the cache misses in order to generate the value to cache await creator.ensureInit().catch(error => { throw new Error(`Init failed with: ${error}`) });
function (cb) { const image = await creator.createCard(characterId, customImage, language).catch(error => { throw new Error(`Create card failed with: ${error}`) });
creator.ensureInit().then(() => creator.createCard(req.params.charaId), (reason) => cb('Init failed: ' + reason, null)).then(image => cb(null, {
return {
binary: { binary: {
image: image, image,
}
})).catch((reason) => cb('createCard failed: ' + reason, null));
}, },
// Options, see node-cache-manager for more examples };
{ ttl: ttl }, });
function (err, result) {
if (err !== null) {
console.error(err);
res.status(500).send({status: "error", reason: err});
return;
} }
res.status(200).send({status: "ok", url: `/characters/id/${req.params.charaId}.png`}); function getOriginalQueryString(req) {
const url = new URL(req.originalUrl, 'http://example.org');
return url.search;
} }
);
app.get('/prepare/id/:characterId', limiter, (req, res, next) => {
const language = typeof req.query.lang === 'string' && supportedLanguages.includes(req.query.lang) ? req.query.lang : supportedLanguages[0];
cacheCreateCard(req.params.characterId, null, language)
.then(() => {
res.status(200).json({
status: 'ok',
url: `/characters/id/${req.params.characterId}.png`,
});
}) })
.catch(next);
});
app.get('/prepare/name/:world/:charName', async (req, res) => { app.get('/prepare/name/:world/:characterName', limiter, (req, res, next) => {
var id = await getCharIdByName(req.params.world, req.params.charName); getCharacterIdByName(req.params.world, req.params.characterName)
.then(characterId => {
if (id === undefined) { if (characterId == null) {
res.status(404).send({status: "error", reason: "Character not found."}); res.status(404).send({ status: 'error', reason: 'Character not found.' });
return; } else {
res.redirect(`/prepare/id/${characterId}${getOriginalQueryString(req)}`);
} }
res.redirect(`/prepare/id/${id}`);
}) })
.catch(next);
});
app.get('/characters/id/:charaId.png', async (req, res) => { app.get('/characters/id/:characterId.png', limiter, (req, res, next) => {
var cacheKey = `img:${req.params.charaId}`; const language = typeof req.query.lang === 'string' && supportedLanguages.includes(req.query.lang) ? req.query.lang : supportedLanguages[0];
var ttl = 60 * 60 * 4; // 4 hours
diskCache.wrap(cacheKey, cacheCreateCard(req.params.characterId, null, language)
// called if the cache misses in order to generate the value to cache .then(result => {
function (cb) { const image = result.binary.image;
creator.ensureInit().then(() => creator.createCard(req.params.charaId), (reason) => cb('Init failed: ' + reason, null)).then(image => cb(null, {
binary: {
image: image,
}
})).catch((reason) => cb('createCard failed: ' + reason, null));
},
// Options, see node-cache-manager for more examples
{ ttl: ttl },
function (err, result) {
if (err !== null) {
console.error(err);
res.status(500).send({status: "error", reason: err});
return;
}
var image = result.binary.image;
res.writeHead(200, { res.writeHead(200, {
'Cache-Control': 'public, max-age=14400',
'Content-Length': Buffer.byteLength(image),
'Content-Type': 'image/png', 'Content-Type': 'image/png',
'Content-Length': image.length,
'Cache-Control': 'public, max-age=14400'
}); });
res.end(image, 'binary'); res.end(image, 'binary');
})
.catch(next);
});
var usedStreams = ['image']; app.get('/characters/id/:characterId', (req, res) => {
// you have to do the work to close the unused files res.redirect(`/characters/id/${req.params.characterId}.png${getOriginalQueryString(req)}`);
// to prevent file descriptors leak });
for (var key in result.binary) {
if (!result.binary.hasOwnProperty(key)) continue; app.get('/characters/name/:world/:characterName.png', limiter, (req, res, next) => {
if (usedStreams.indexOf(key) < 0 getCharacterIdByName(req.params.world, req.params.characterName)
&& result.binary[key] instanceof Stream.Readable) { .then(characterId => {
if (typeof result.binary[key].close === 'function') { if (characterId == null) {
result.binary[key].close(); // close the stream (fs has it) res.status(404).send({ status: 'error', reason: 'Character not found.' });
} else { } else {
result.binary[key].resume(); // resume to the end and close res.redirect(`/characters/id/${characterId}${getOriginalQueryString(req)}`);
} }
}
}
}
);
}) })
.catch(next);
});
app.get('/characters/id/:charaId', async (req, res) => { app.get('/characters/name/:world/:characterName', (req, res) => {
res.redirect(`/characters/id/${req.params.charaId}.png`); res.redirect(`/characters/name/${req.params.world}/${req.params.characterName}.png${getOriginalQueryString(req)}`);
}) });
app.get('/characters/name/:world/:charName.png', async (req, res) => {
var id = await getCharIdByName(req.params.world, req.params.charName);
if (id === undefined) {
res.status(404).send({status: "error", reason: "Character not found."});
return;
}
res.redirect(`/characters/id/${id}.png`);
})
app.get('/characters/name/:world/:charName', async (req, res) => {
res.redirect(`/characters/name/${req.params.world}/${req.params.charName}.png`);
})
app.get('/', async (req, res) => { app.get('/', async (req, res) => {
res.redirect('https://github.com/ArcaneDisgea/XIV-Character-Cards'); res.redirect('https://github.com/xivapi/XIV-Character-Cards');
}) });
app.use((error, req, res, next) => {
console.error(error);
res.status(500).json({
status: 'error',
reason: error instanceof Error ? error.stack : String(error),
});
});
app.listen(port, () => { app.listen(port, () => {
console.log(`Listening at http://localhost:${port}`) console.log(`Listening at http://localhost:${port}`);
}) creator.ensureInit().then(() => console.log('CardCreator initialization complete'));
});

View file

@ -1,7 +1,7 @@
{ {
"name": "xiv-character-cards", "name": "xiv-character-cards",
"description": "API to create fancy cards for FFXIV characters based on their Lodestone data, hosted at https://ffxiv-character-cards.herokuapp.com.", "description": "API to create fancy cards for FFXIV characters based on their Lodestone data, hosted at https://ffxiv-character-cards.herokuapp.com.",
"version": "1.2.1", "version": "1.3.0",
"main": "create-card.js", "main": "create-card.js",
"repository": "https://github.com/xivapi/XIV-Character-Cards.git", "repository": "https://github.com/xivapi/XIV-Character-Cards.git",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
@ -16,15 +16,16 @@
"docs:deploy": "npm run docs:generate && node gh-pages.js" "docs:deploy": "npm run docs:generate && node gh-pages.js"
}, },
"dependencies": { "dependencies": {
"cache-manager-fs": "^1.0.8", "cache-manager": "^3.4.4",
"cache-manager-fs-binary": "^1.0.4", "cache-manager-fs-binary": "^1.0.4",
"canvas": "^2.6.1", "canvas": "^2.8.0",
"express": "^4.17.1", "express": "^4.17.1",
"node-fetch": "^2.6.1" "express-rate-limit": "^5.5.0",
"node-fetch": "^2.6.5"
}, },
"devDependencies": { "devDependencies": {
"gh-pages": "^3.2.0", "gh-pages": "^3.2.3",
"jsdoc": "^3.6.7", "jsdoc": "^3.6.7",
"nodemon": "^2.0.7" "nodemon": "^2.0.14"
} }
} }

View file

@ -1,78 +1,76 @@
# XIV Character Cards # XIV Character Cards
![npm Version](https://img.shields.io/npm/v/xiv-character-cards) ![npm Version](https://img.shields.io/npm/v/xiv-character-cards)
[![Documentation](https://img.shields.io/badge/docs-JSDoc-orange)](https://xivapi.github.io/XIV-Character-Cards/) [![Documentation](https://img.shields.io/badge/docs-JSDoc-orange)](https://xivapi.github.io/XIV-Character-Cards/)
API to create fancy cards for FFXIV characters based on their Lodestone data, hosted at https://ffxiv-character-cards.herokuapp.com. Library and API to create fancy cards for FFXIV characters based on their Lodestone data, powered by [xivapi.com](https://xivapi.com/) and hosted at [https://ffxiv-character-cards.herokuapp.com](https://ffxiv-character-cards.herokuapp.com).
![Demo image](https://ffxiv-character-cards.herokuapp.com/characters/id/9575452.png) ![Demo image](https://ffxiv-character-cards.herokuapp.com/characters/id/9575452.png)
## Endpoints ## API
### Getting images All API calls support the `lang` query parameter to create a character card with information in the specified language. The supported languages are the same as [xivapi.com](https://xivapi.com/docs/Common-Features#language), which are English (en), Japanese (ja), German (de) and French (fr).
``https://ffxiv-character-cards.herokuapp.com/characters/id/<LODESTONE ID>.png`` E.g. a request for a german character card would look like this: ``https://ffxiv-character-cards.herokuapp.com/characters/id/<LODESTONE ID>.png?lang=de``
<br>Get the PNG for a character by its Lodestone ID.
### Getting a card for a character by its Lodestone ID
``GET https://ffxiv-character-cards.herokuapp.com/characters/id/<LODESTONE ID>.png``
### Getting card for a character by its world and name
``GET https://ffxiv-character-cards.herokuapp.com/characters/name/<WORLD>/<CHARACTER NAME>.png``
<br>**Note:** This is considerably slower than the creation by ID, since the character has to be looked up in the Lodestone first.
<br> <br>
``https://ffxiv-character-cards.herokuapp.com/characters/name/<WORLD>/<CHARACTER NAME>.png`` If you are using this API together with an application that requires the API to respond very quickly, like Discord, you may need to ask it to "prepare" the card image for a character beforehand. The API will reply with its status, and in case of success, the URL to the final image.
<br>Get the PNG for a character by its world and name.
### Requesting images to be cached
If you are using this API together with an application that requires the API to respond very quickly, like Discord, you need to ask it to "prepare" the image for a character beforehand.
``https://ffxiv-character-cards.herokuapp.com/prepare/id/<LODESTONE ID>``
<br>Request a character image to be cached by its Lodestone ID.
<br>
``https://ffxiv-character-cards.herokuapp.com/prepare/name/<WORLD>/<CHARACTER NAME>``
<br>Request a character image to be cached by its world and name.
The API will reply with its status, and in case of success, the URL to the final image.
``{"status":"ok","url":"/characters/id/123456789.png"}`` ``{"status":"ok","url":"/characters/id/123456789.png"}``
## Using in your application ### Requesting a card to be cached for a character by its Lodestone ID
``` ``GET https://ffxiv-character-cards.herokuapp.com/prepare/id/<LODESTONE ID>``
### Requesting a card to be cached for a character by its world and name
``GET https://ffxiv-character-cards.herokuapp.com/prepare/name/<WORLD>/<CHARACTER NAME>``
## Library
To use the card creator as a library in your Node.JS application, first install it as a dependency with:
```sh
yarn add xiv-character-cards yarn add xiv-character-cards
# or # or
npm i xiv-character-cards npm i xiv-character-cards
``` ```
You will receive a PNG-buffer for you to use in your bot or application.<br>Check ``index.js`` for other usage examples. You can then instantiate the class `CardCreator` from the library, call the asynchronous `insureInit()` function to make sure all resources are loaded and then use the asynchronous `createCard()` function with your characters Lodestone ID. You will receive a promise that resolves to a `Buffer` of the PNG image of your card, that you can use in your bot or application.
### Example Check the [library documentation](https://xivapi.github.io/XIV-Character-Cards/) for more details.
> **Note:** The API server is not published as an NPM package, so if you want to host it yourself, clone the [Github repository](https://github.com/xivapi/XIV-Character-Cards) and put the Express.JS webserver defined in the `index.js` file behind a reverse proxy.
### Library example
```js ```js
const { CardCreator } = require("xiv-character-cards"); const { CardCreator } = require("xiv-character-cards");
const fs = require("fs"); const { writeFileSync } = require("fs");
const card = new CardCreator(); const creator = new CardCreator();
const lodestoneid = "13821878"; const lodestoneId = "13821878";
function example(cb) { async function example() {
card.ensureInit() await creator.ensureInit();
.then( return creator.createCard(lodestoneId);
() => card.createCard(lodestoneid), }
(reason) => cb("Init failed: " + reason, null)
) example()
.then((image) => .then(card => {
cb(null, { writeFileSync(`./${lodestoneId}.png`, card);
binary: {
image: image,
},
}) })
) .catch(error => {
.catch((reason) => cb("createCard failed: " + reason, null)); console.error('Creator initialization or card creation failed!');
} console.error(error);
example((err, response) => {
const buffer = response.binary.image;
fs.writeFileSync(`./${lodestoneid}.png`, response.binary.image, (err) => {
if (err) {
console.log(err);
}
});
}); });
``` ```

View file

Before

Width:  |  Height:  |  Size: 596 KiB

After

Width:  |  Height:  |  Size: 596 KiB

View file

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View file

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

View file

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

View file

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View file

Before

Width:  |  Height:  |  Size: 3.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

View file

Before

Width:  |  Height:  |  Size: 2 KiB

After

Width:  |  Height:  |  Size: 2 KiB

View file

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

View file

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View file

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.9 KiB

View file

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View file

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

Before

Width:  |  Height:  |  Size: 4 KiB

After

Width:  |  Height:  |  Size: 4 KiB

View file

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 3 KiB

View file

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

Before

Width:  |  Height:  |  Size: 3 KiB

After

Width:  |  Height:  |  Size: 3 KiB

View file

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

View file

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

View file

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

View file

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

View file

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

View file

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View file

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View file

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View file

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View file

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View file

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View file

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 37 KiB

View file

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View file

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View file

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View file

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View file

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 44 KiB

View file

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View file

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View file

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View file

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View file

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View file

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 34 KiB

View file

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View file

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View file

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View file

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 42 KiB

View file

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 27 KiB

View file

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View file

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

View file

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View file

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View file

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

View file

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

View file

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 24 KiB

View file

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

View file

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 35 KiB

View file

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

View file

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 26 KiB

View file

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 47 KiB

View file

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

View file

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 30 KiB

View file

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

View file

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

View file

Before

Width:  |  Height:  |  Size: 8.2 KiB

After

Width:  |  Height:  |  Size: 8.2 KiB

View file

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View file

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 57 KiB

908
yarn.lock

File diff suppressed because it is too large Load diff