feat(#17): Add an appraisal compare-by-location page
This commit is contained in:
@@ -14,7 +14,7 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
autoClose: true
|
||||
})
|
||||
|
||||
const isOpen = ref(false);
|
||||
const isOpen = defineModel<boolean>('open', {default: false});
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
const floating = ref<HTMLElement | null>(null);
|
||||
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
ReprocessingApi,
|
||||
RuleBookApi,
|
||||
RuleScriptApi,
|
||||
TransactionApi
|
||||
TransactionApi,
|
||||
UniverseApi
|
||||
} from "@/generated/mammon";
|
||||
|
||||
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 acquisitionApi = new AcquisitionApi(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);
|
||||
@@ -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>
|
||||
@@ -8,4 +8,4 @@ export type MarketTypePrice = {
|
||||
orderCount: number;
|
||||
};
|
||||
|
||||
export type PriceGetter = (types: MarketType[]) => Promise<MarketTypePrice[]>;
|
||||
export type PriceGetter = (types: MarketType[], locationId?: number) => Promise<MarketTypePrice[]>;
|
||||
|
||||
@@ -13,14 +13,14 @@ export const useAppraisalStore = defineStore('appraisal', () => {
|
||||
|
||||
const getPricesUncached = getMammonPrices;
|
||||
|
||||
const getPrice = async (type: MarketType, regionId?: number): Promise<MarketTypePrice> => (await getPrices([type], regionId))[0];
|
||||
const getPrices = async (types: MarketType[], regionId?: number): Promise<MarketTypePrice[]> => {
|
||||
const getPrice = async (type: MarketType, locationId?: number): Promise<MarketTypePrice> => (await getPrices([type], locationId))[0];
|
||||
const getPrices = async (types: MarketType[], locationId?: number): Promise<MarketTypePrice[]> => {
|
||||
const cached: MarketTypePrice[] = [];
|
||||
const uncached: MarketType[] = [];
|
||||
const rId = regionId ?? jitaId;
|
||||
const lId = locationId ?? jitaId;
|
||||
|
||||
types.forEach(t => {
|
||||
const cachedPrice = cache.get(rId, t.id);
|
||||
const cachedPrice = cache.get(lId, t.id);
|
||||
|
||||
if (cachedPrice) {
|
||||
cached.push(cachedPrice);
|
||||
@@ -33,12 +33,12 @@ export const useAppraisalStore = defineStore('appraisal', () => {
|
||||
const batches: Promise<MarketTypePrice[]>[] = [];
|
||||
|
||||
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();
|
||||
|
||||
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;
|
||||
|
||||
@@ -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()];
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
export * from './MarketTypePrice';
|
||||
export * from './appraisal';
|
||||
export * from './appraise';
|
||||
export * from './compare';
|
||||
|
||||
export { default as AppraisalResultTable } from './AppraisalResultTable.vue';
|
||||
export { default as CompareResultTable } from './CompareResultTable.vue';
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import {marketApi} from '@/mammon/mammonService';
|
||||
import {MarketTypePrice, PriceGetter} from './MarketTypePrice';
|
||||
|
||||
export const getMammonPrices: PriceGetter = async types => {
|
||||
export const getMammonPrices: PriceGetter = async (types, locationId) => {
|
||||
if (types.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
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) => {
|
||||
const type = typesById.get(p.marketTypeId);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export * from './RegionalMarketCache';
|
||||
export * from './history';
|
||||
export * from './location';
|
||||
export * from './order';
|
||||
export * from './tax';
|
||||
export * from './type';
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './MarketLocation';
|
||||
export { default as MarketLocationInput } from './MarketLocationInput.vue';
|
||||
@@ -1 +1 @@
|
||||
export const jitaId = 10000002;
|
||||
export const jitaId = 60003760;
|
||||
@@ -18,6 +18,9 @@ import {routeNames} from '@/routes';
|
||||
<RouterLink :to="{name: routeNames.appraise}" class="tab">
|
||||
<span>Appraisal</span>
|
||||
</RouterLink>
|
||||
<RouterLink :to="{name: routeNames.compareAppraisal}" class="tab">
|
||||
<span>Compare</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<RouterView />
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import {Dropdown} from '@/components';
|
||||
import {AppraisalItemValue, AppraisalResultTable, appraise} from '@/market/appraisal';
|
||||
import {ref} from 'vue';
|
||||
|
||||
const items = ref('');
|
||||
const showInputs = ref(true);
|
||||
|
||||
const result = ref<AppraisalItemValue[]>([]);
|
||||
|
||||
@@ -10,15 +12,18 @@ const send = async () => result.value = await appraise(items.value);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-stretch mt-4">
|
||||
<div class="flex-1 mx-1">
|
||||
<span>Items</span>
|
||||
<textarea class="mt-1" v-model="items" placeholder="Paste an EVE inventory listing" />
|
||||
<Dropdown inline :auto-close="false" v-model:open="showInputs" class="mt-4">
|
||||
<template #button>Items</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>
|
||||
<div class="grid my-2">
|
||||
<button class="justify-self-end" @click="send" :disabled="!items.trim()">Send</button>
|
||||
</div>
|
||||
<div class="grid my-2">
|
||||
<button class="justify-self-end" @click="send" :disabled="!items.trim()">Send</button>
|
||||
</div>
|
||||
</Dropdown>
|
||||
<template v-if="result.length > 0">
|
||||
<hr />
|
||||
<div class="grid mt-2">
|
||||
|
||||
@@ -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 & 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>
|
||||
@@ -16,6 +16,7 @@ export const routeNames = {
|
||||
editRuleBook: 'edit-rule-book',
|
||||
marketTypes: 'market-types',
|
||||
appraise: 'appraise',
|
||||
compareAppraisal: 'compare-appraisal',
|
||||
about: 'about',
|
||||
} as const;
|
||||
|
||||
@@ -41,6 +42,7 @@ export const routes: RouteRecordRaw[] = [
|
||||
{path: 'scan', component: () => import('@/pages/market/Scan.vue')},
|
||||
{path: 'acquisitions', component: () => import('@/pages/market/Acquisitions.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')},
|
||||
|
||||
Reference in New Issue
Block a user