feat(#17): Add an appraisal compare-by-location page

This commit is contained in:
Sirttas
2026-07-09 19:06:53 +02:00
parent c4803ef650
commit 81250953eb
17 changed files with 361 additions and 21 deletions
+1 -1
View File
@@ -14,7 +14,7 @@ const props = withDefaults(defineProps<Props>(), {
autoClose: true autoClose: true
}) })
const isOpen = ref(false); const isOpen = defineModel<boolean>('open', {default: false});
const root = ref<HTMLElement | null>(null); const root = ref<HTMLElement | null>(null);
const floating = ref<HTMLElement | null>(null); const floating = ref<HTMLElement | null>(null);
+4 -2
View File
@@ -12,7 +12,8 @@ import {
ReprocessingApi, ReprocessingApi,
RuleBookApi, RuleBookApi,
RuleScriptApi, RuleScriptApi,
TransactionApi TransactionApi,
UniverseApi
} from "@/generated/mammon"; } from "@/generated/mammon";
export const mammonUrl = import.meta.env.VITE_MAMMON_URL; export const mammonUrl = import.meta.env.VITE_MAMMON_URL;
@@ -105,4 +106,5 @@ export const ruleScriptApi = new RuleScriptApi(undefined, mammonUrl, mammonAxios
export const activityApi = new ActivityApi(undefined, mammonUrl, mammonAxiosInstance); export const activityApi = new ActivityApi(undefined, mammonUrl, mammonAxiosInstance);
export const acquisitionApi = new AcquisitionApi(undefined, mammonUrl, mammonAxiosInstance); export const acquisitionApi = new AcquisitionApi(undefined, mammonUrl, mammonAxiosInstance);
export const marketApi = new MarketApi(undefined, mammonUrl, mammonAxiosInstance); export const marketApi = new MarketApi(undefined, mammonUrl, mammonAxiosInstance);
export const reprocessingApi = new ReprocessingApi(undefined, mammonUrl, mammonAxiosInstance); export const reprocessingApi = new ReprocessingApi(undefined, mammonUrl, mammonAxiosInstance);
export const universeApi = new UniverseApi(undefined, mammonUrl, mammonAxiosInstance);
+128
View File
@@ -0,0 +1,128 @@
<script setup lang="ts">
import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table';
import {MarketTypeLabel} from '@/market/type';
import {computed} from 'vue';
import {MarketLocation} from '../location';
import IskLabel from '../IskLabel.vue';
import {ComparedItem} from './compare';
interface Props {
result?: ComparedItem[];
locations?: MarketLocation[];
}
const props = withDefaults(defineProps<Props>(), {
result: () => [],
locations: () => []
});
const buyKey = (id: number): `buy:${number}` => `buy:${id}`;
const sellKey = (id: number): `sell:${number}` => `sell:${id}`;
type SortableItem = ComparedItem & Record<`buy:${number}` | `sell:${number}`, number>;
const rows = computed<SortableItem[]>(() => props.result.map(item => {
const sortable = {...item} as SortableItem;
props.locations.forEach(l => {
const price = item.prices[l.id];
sortable[buyKey(l.id)] = price?.buy ?? 0;
sortable[sellKey(l.id)] = price?.sell ?? 0;
});
return sortable;
}));
const {sortedArray, headerProps} = useSort(rows, {
defaultSortKey: 'name',
defaultSortDirection: 'asc'
});
const bestBuy = (row: ComparedItem): number | undefined => {
let best: number | undefined;
let bestValue = -Infinity;
props.locations.forEach(l => {
const cell = row.prices[l.id];
if (cell && cell.buy > bestValue) {
bestValue = cell.buy;
best = l.id;
}
});
return bestValue > 0 ? best : undefined;
};
const bestSell = (row: ComparedItem): number | undefined => {
let best: number | undefined;
let bestValue = -Infinity;
props.locations.forEach(l => {
const cell = row.prices[l.id];
if (cell && cell.sell > bestValue) {
bestValue = cell.sell;
best = l.id;
}
});
return bestValue > 0 ? best : undefined;
};
const totals = computed(() => new Map(props.locations.map(l => [l.id, props.result.reduce((acc, row) => {
const cell = row.prices[l.id];
return {
buy: acc.buy + (cell?.buy ?? 0),
sell: acc.sell + (cell?.sell ?? 0),
};
}, {buy: 0, sell: 0})])));
</script>
<template>
<VirtualScrollTable :list="sortedArray" :itemHeight="33" :headerHeight="66" :footerHeight="33" bottom="1rem">
<template #default="{ list }">
<thead>
<tr>
<SortableHeader v-bind="headerProps" sortKey="name" rowspan="2">Item</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="quantity" rowspan="2">Qty</SortableHeader>
<th v-for="location in locations" :key="location.id" colspan="2" class="text-center border-l border-slate-500" :title="location.name">
{{ location.systemName }}
</th>
</tr>
<tr>
<template v-for="location in locations" :key="location.id">
<SortableHeader v-bind="headerProps" :sortKey="buyKey(location.id)" class="border-l border-slate-500">Buy</SortableHeader>
<SortableHeader v-bind="headerProps" :sortKey="sellKey(location.id)">Sell</SortableHeader>
</template>
</tr>
</thead>
<tbody>
<tr v-for="r in list" :key="r.data.typeId">
<td>
<MarketTypeLabel :id="r.data.typeId" :name="r.data.name" />
</td>
<td class="text-right">{{ r.data.quantity.toLocaleString() }}</td>
<template v-for="location in locations" :key="location.id">
<td class="text-right border-l border-slate-500" :class="r.data.prices[location.id] ? (bestBuy(r.data) === location.id ? 'bg-emerald-500' : 'bg-amber-900') : ''">
<IskLabel v-if="r.data.prices[location.id]" :amount="r.data.prices[location.id].buy" :colored="false" />
<span v-else>-</span>
</td>
<td class="text-right" :class="r.data.prices[location.id] ? (bestSell(r.data) === location.id ? 'bg-emerald-500' : 'bg-amber-900') : ''">
<IskLabel v-if="r.data.prices[location.id]" :amount="r.data.prices[location.id].sell" :colored="false" />
<span v-else>-</span>
</td>
</template>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="2" class="font-semibold">Total</td>
<template v-for="location in locations" :key="location.id">
<td class="text-right border-l border-slate-500"><IskLabel :amount="totals.get(location.id)?.buy ?? 0" :colored="false" /></td>
<td class="text-right"><IskLabel :amount="totals.get(location.id)?.sell ?? 0" :colored="false" /></td>
</template>
</tr>
</tfoot>
</template>
<template #empty>
<div class="text-center mt-4">
<span>No items found</span>
</div>
</template>
</VirtualScrollTable>
</template>
+1 -1
View File
@@ -8,4 +8,4 @@ export type MarketTypePrice = {
orderCount: number; orderCount: number;
}; };
export type PriceGetter = (types: MarketType[]) => Promise<MarketTypePrice[]>; export type PriceGetter = (types: MarketType[], locationId?: number) => Promise<MarketTypePrice[]>;
+6 -6
View File
@@ -13,14 +13,14 @@ export const useAppraisalStore = defineStore('appraisal', () => {
const getPricesUncached = getMammonPrices; const getPricesUncached = getMammonPrices;
const getPrice = async (type: MarketType, regionId?: number): Promise<MarketTypePrice> => (await getPrices([type], regionId))[0]; const getPrice = async (type: MarketType, locationId?: number): Promise<MarketTypePrice> => (await getPrices([type], locationId))[0];
const getPrices = async (types: MarketType[], regionId?: number): Promise<MarketTypePrice[]> => { const getPrices = async (types: MarketType[], locationId?: number): Promise<MarketTypePrice[]> => {
const cached: MarketTypePrice[] = []; const cached: MarketTypePrice[] = [];
const uncached: MarketType[] = []; const uncached: MarketType[] = [];
const rId = regionId ?? jitaId; const lId = locationId ?? jitaId;
types.forEach(t => { types.forEach(t => {
const cachedPrice = cache.get(rId, t.id); const cachedPrice = cache.get(lId, t.id);
if (cachedPrice) { if (cachedPrice) {
cached.push(cachedPrice); cached.push(cachedPrice);
@@ -33,12 +33,12 @@ export const useAppraisalStore = defineStore('appraisal', () => {
const batches: Promise<MarketTypePrice[]>[] = []; const batches: Promise<MarketTypePrice[]>[] = [];
for (let i = 0; i < uncached.length; i += BATCH_SIZE) { for (let i = 0; i < uncached.length; i += BATCH_SIZE) {
batches.push(getPricesUncached(uncached.slice(i, i + BATCH_SIZE))); batches.push(getPricesUncached(uncached.slice(i, i + BATCH_SIZE), lId));
} }
const prices = (await Promise.all(batches)).flat(); const prices = (await Promise.all(batches)).flat();
prices.forEach(p => cache.set(rId, p.type.id, p)); prices.forEach(p => cache.set(lId, p.type.id, p));
return [ ...cached, ...prices ]; return [ ...cached, ...prices ];
} }
return cached; return cached;
+66
View File
@@ -0,0 +1,66 @@
import {marketApi} from '@/mammon';
import {getMarketTypes, MarketType} from '../type';
import {MarketTypePrice} from './MarketTypePrice';
export type LocationPrice = {
buyUnit: number;
sellUnit: number;
buy: number;
sell: number;
};
export type ComparedItem = {
typeId: number;
name: string;
quantity: number;
volume: number;
prices: Record<number, LocationPrice>;
};
export type PricesForLocation = (types: MarketType[], locationId: number) => Promise<MarketTypePrice[]>;
export const compareAppraisal = async (
raw: string,
locationIds: number[],
getPrices: PricesForLocation
): Promise<ComparedItem[]> => {
if (!raw?.trim() || locationIds.length === 0) {
return [];
}
const stacks = await marketApi.parseStacks(raw).then(r => r.data);
if (stacks.length === 0) {
return [];
}
const quantities = new Map(stacks.map(s => [s.marketTypeId, s.quantity]));
const types = await getMarketTypes(stacks.map(s => s.marketTypeId));
const rows = new Map<number, ComparedItem>(types.map(t => [t.id, {
typeId: t.id,
name: t.name,
quantity: quantities.get(t.id) ?? 0,
volume: t.volume,
prices: {},
}]));
const pricesByLocation = await Promise.all(
locationIds.map(async locationId => [locationId, await getPrices(types, locationId)] as const)
);
pricesByLocation.forEach(([locationId, prices]) => prices.forEach(p => {
const row = rows.get(p.type.id);
if (row) {
row.prices[locationId] = {
buyUnit: p.buy,
sellUnit: p.sell,
buy: p.buy * row.quantity,
sell: p.sell * row.quantity,
};
}
}));
return [...rows.values()];
};
+2
View File
@@ -1,5 +1,7 @@
export * from './MarketTypePrice'; export * from './MarketTypePrice';
export * from './appraisal'; export * from './appraisal';
export * from './appraise'; export * from './appraise';
export * from './compare';
export { default as AppraisalResultTable } from './AppraisalResultTable.vue'; export { default as AppraisalResultTable } from './AppraisalResultTable.vue';
export { default as CompareResultTable } from './CompareResultTable.vue';
+2 -2
View File
@@ -1,13 +1,13 @@
import {marketApi} from '@/mammon/mammonService'; import {marketApi} from '@/mammon/mammonService';
import {MarketTypePrice, PriceGetter} from './MarketTypePrice'; import {MarketTypePrice, PriceGetter} from './MarketTypePrice';
export const getMammonPrices: PriceGetter = async types => { export const getMammonPrices: PriceGetter = async (types, locationId) => {
if (types.length === 0) { if (types.length === 0) {
return []; return [];
} }
const typesById = new Map(types.map(t => [t.id, t])); const typesById = new Map(types.map(t => [t.id, t]));
const response = await marketApi.currentPrices(types.map(t => t.id)); const response = await marketApi.currentPrices(types.map(t => t.id), locationId);
return response.data.reduce<MarketTypePrice[]>((prices, p) => { return response.data.reduce<MarketTypePrice[]>((prices, p) => {
const type = typesById.get(p.marketTypeId); const type = typesById.get(p.marketTypeId);
+1
View File
@@ -1,5 +1,6 @@
export * from './RegionalMarketCache'; export * from './RegionalMarketCache';
export * from './history'; export * from './history';
export * from './location';
export * from './order'; export * from './order';
export * from './tax'; export * from './tax';
export * from './type'; export * from './type';
+15
View File
@@ -0,0 +1,15 @@
import {universeApi} from '@/mammon/mammonService';
import type {MarketLocationResponse} from '@/generated/mammon';
export type MarketLocation = MarketLocationResponse;
const cache = new Map<number, MarketLocation>();
export const searchMarketLocations = async (search: string): Promise<MarketLocation[]> => {
if (search.length === 0) {
return [];
}
const locations = await universeApi.searchLocations(search).then(r => r.data);
locations.forEach(l => cache.set(l.id, l));
return locations;
};
@@ -0,0 +1,51 @@
<script setup lang="ts">
import {vOnClickOutside} from '@vueuse/components';
import {watchDebounced} from '@vueuse/core';
import {ref, watch} from 'vue';
import {MarketLocation, searchMarketLocations} from './MarketLocation';
const modelValue = defineModel<MarketLocation>();
const isOpen = ref(false);
const search = ref(modelValue.value?.name ?? '');
const suggestions = ref<MarketLocation[]>([]);
const select = (location: MarketLocation) => {
modelValue.value = location;
search.value = location.name;
suggestions.value = [];
isOpen.value = false;
};
watch(() => modelValue.value, v => {
search.value = v?.name ?? '';
});
watchDebounced(search, async value => {
const term = value.trim();
if (!isOpen.value || term.length < 3 || term === modelValue.value?.name) {
suggestions.value = [];
} else {
suggestions.value = await searchMarketLocations(term);
}
}, {debounce: 300});
</script>
<template>
<div @click="isOpen = true" v-on-click-outside="() => isOpen = false">
<input type="text" class="w-96" v-model="search" placeholder="Search a market location…" />
<div v-if="isOpen && suggestions.length > 0" class="z-20 absolute w-96">
<div class="rounded-b bg-slate-500 max-h-72 overflow-y-auto">
<div
v-for="s in suggestions"
:key="s.id"
class="px-1 py-0.5 cursor-pointer whitespace-nowrap overflow-hidden hover:bg-slate-700"
@click="select(s)"
>
{{ s.name }} <span class="text-slate-300">({{ s.regionName }})</span>
</div>
</div>
</div>
</div>
</template>
+2
View File
@@ -0,0 +1,2 @@
export * from './MarketLocation';
export { default as MarketLocationInput } from './MarketLocationInput.vue';
+1 -1
View File
@@ -1 +1 @@
export const jitaId = 10000002; export const jitaId = 60003760;
+3
View File
@@ -18,6 +18,9 @@ import {routeNames} from '@/routes';
<RouterLink :to="{name: routeNames.appraise}" class="tab"> <RouterLink :to="{name: routeNames.appraise}" class="tab">
<span>Appraisal</span> <span>Appraisal</span>
</RouterLink> </RouterLink>
<RouterLink :to="{name: routeNames.compareAppraisal}" class="tab">
<span>Compare</span>
</RouterLink>
</div> </div>
<RouterView /> <RouterView />
</div> </div>
+13 -8
View File
@@ -1,8 +1,10 @@
<script setup lang="ts"> <script setup lang="ts">
import {Dropdown} from '@/components';
import {AppraisalItemValue, AppraisalResultTable, appraise} from '@/market/appraisal'; import {AppraisalItemValue, AppraisalResultTable, appraise} from '@/market/appraisal';
import {ref} from 'vue'; import {ref} from 'vue';
const items = ref(''); const items = ref('');
const showInputs = ref(true);
const result = ref<AppraisalItemValue[]>([]); const result = ref<AppraisalItemValue[]>([]);
@@ -10,15 +12,18 @@ const send = async () => result.value = await appraise(items.value);
</script> </script>
<template> <template>
<div class="flex items-stretch mt-4"> <Dropdown inline :auto-close="false" v-model:open="showInputs" class="mt-4">
<div class="flex-1 mx-1"> <template #button>Items</template>
<span>Items</span> <div class="flex items-stretch mt-2">
<textarea class="mt-1" v-model="items" placeholder="Paste an EVE inventory listing" /> <div class="flex-1 mx-1">
<span>Items</span>
<textarea class="mt-1" v-model="items" placeholder="Paste an EVE inventory listing" />
</div>
</div> </div>
</div> <div class="grid my-2">
<div class="grid my-2"> <button class="justify-self-end" @click="send" :disabled="!items.trim()">Send</button>
<button class="justify-self-end" @click="send" :disabled="!items.trim()">Send</button> </div>
</div> </Dropdown>
<template v-if="result.length > 0"> <template v-if="result.length > 0">
<hr /> <hr />
<div class="grid mt-2"> <div class="grid mt-2">
+63
View File
@@ -0,0 +1,63 @@
<script setup lang="ts">
import {ref} from 'vue';
import {Dropdown} from '@/components';
import {compareAppraisal, ComparedItem, CompareResultTable, useAppraisalStore} from '@/market/appraisal';
import {MarketLocation, MarketLocationInput} from '@/market/location';
const store = useAppraisalStore();
const items = ref('');
const from = ref<MarketLocation>();
const to = ref<MarketLocation>();
const showInputs = ref(true);
const result = ref<ComparedItem[]>([]);
const resultLocations = ref<MarketLocation[]>([]);
const loading = ref(false);
const send = async () => {
const selected = [from.value, to.value].filter((l): l is MarketLocation => l !== undefined);
loading.value = true;
try {
result.value = await compareAppraisal(items.value, selected.map(l => l.id), store.getPrices);
resultLocations.value = selected;
} finally {
loading.value = false;
}
};
</script>
<template>
<Dropdown inline :auto-close="false" v-model:open="showInputs" class="mt-4">
<template #button>Items &amp; locations</template>
<div class="flex items-stretch mt-2">
<div class="flex-1 mx-1">
<span>Items</span>
<textarea class="mt-1" v-model="items" placeholder="Paste an EVE inventory listing" />
</div>
</div>
<div class="flex flex-wrap gap-6 mx-1 mt-2">
<div>
<span>From</span>
<div class="mt-1">
<MarketLocationInput v-model="from" />
</div>
</div>
<div>
<span>To</span>
<div class="mt-1">
<MarketLocationInput v-model="to" />
</div>
</div>
</div>
<div class="grid my-2">
<button class="justify-self-end" @click="send" :disabled="!items.trim() || !from || !to || loading">Send</button>
</div>
</Dropdown>
<template v-if="result.length > 0">
<hr />
<div class="grid mt-2">
<CompareResultTable :result="result" :locations="resultLocations" />
</div>
</template>
</template>
+2
View File
@@ -16,6 +16,7 @@ export const routeNames = {
editRuleBook: 'edit-rule-book', editRuleBook: 'edit-rule-book',
marketTypes: 'market-types', marketTypes: 'market-types',
appraise: 'appraise', appraise: 'appraise',
compareAppraisal: 'compare-appraisal',
about: 'about', about: 'about',
} as const; } as const;
@@ -41,6 +42,7 @@ export const routes: RouteRecordRaw[] = [
{path: 'scan', component: () => import('@/pages/market/Scan.vue')}, {path: 'scan', component: () => import('@/pages/market/Scan.vue')},
{path: 'acquisitions', component: () => import('@/pages/market/Acquisitions.vue')}, {path: 'acquisitions', component: () => import('@/pages/market/Acquisitions.vue')},
{path: 'appraisal', name: routeNames.appraise, component: () => import('@/pages/market/Appraise.vue')}, {path: 'appraisal', name: routeNames.appraise, component: () => import('@/pages/market/Appraise.vue')},
{path: 'appraisal/compare', name: routeNames.compareAppraisal, component: () => import('@/pages/market/CompareAppraisal.vue')},
]}, ]},
{path: '/reprocess', component: () => import('@/pages/Reprocess.vue')}, {path: '/reprocess', component: () => import('@/pages/Reprocess.vue')},