3 Commits
Author SHA1 Message Date
Sirttas 52addba909 feat(#19): Sort by hubs 2026-07-09 19:16:06 +02:00
Sirttas 81250953eb feat(#17): Add an appraisal compare-by-location page 2026-07-09 19:06:53 +02:00
Sirttas c4803ef650 feat(#16): Appraisal page 2026-07-09 17:43:28 +02:00
20 changed files with 498 additions and 16 deletions
+1 -1
View File
@@ -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);
+3 -3
View File
@@ -44,15 +44,15 @@ const submit = () => {
</script>
<template>
<div @click="isOpen = true" v-on-click-outside="() => isOpen = false">
<div class="relative" @click="isOpen = true" v-on-click-outside="() => isOpen = false">
<div class="fake-input" @keyup.enter="submit" @keyup.down="moveDown" @keyup.up="moveUp">
<slot name="input" :value="modelValue" />
</div>
<div v-if="isOpen && items.length" class="z-20 absolute">
<div v-if="isOpen && items.length" class="z-20 absolute w-full">
<div v-bind="containerProps" class="rounded-b" style="height: 300px">
<div v-bind="wrapperProps">
<div v-for="s in list" :key="s.index"
class="hover:bg-slate-700 cursor-pointer"
class="hover:bg-slate-700 cursor-pointer overflow-hidden"
:class="s.index === currentIndex ? 'bg-emerald-500' : 'bg-slate-500'"
@click.stop="select(s.data)">
<slot name="item" :item="s.data" />
+3 -1
View File
@@ -12,7 +12,8 @@ import {
ReprocessingApi,
RuleBookApi,
RuleScriptApi,
TransactionApi
TransactionApi,
UniverseApi
} from "@/generated/mammon";
export const mammonUrl = import.meta.env.VITE_MAMMON_URL;
@@ -106,3 +107,4 @@ export const activityApi = new ActivityApi(undefined, mammonUrl, mammonAxiosInst
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 universeApi = new UniverseApi(undefined, mammonUrl, mammonAxiosInstance);
@@ -0,0 +1,64 @@
<script setup lang="ts">
import {SortableHeader, useSort, VirtualScrollTable} from '@/components/table';
import {MarketTypeLabel} from '@/market/type';
import {computed} from 'vue';
import IskLabel from '../IskLabel.vue';
import {AppraisalItemValue} from './appraise';
interface Props {
result?: AppraisalItemValue[];
}
const props = withDefaults(defineProps<Props>(), {
result: () => []
});
const { sortedArray, headerProps } = useSort(computed(() => props.result), {
defaultSortKey: 'sell',
defaultSortDirection: 'desc'
});
const totalBuy = computed(() => props.result.reduce((sum, r) => sum + r.buy, 0));
const totalSell = computed(() => props.result.reduce((sum, r) => sum + r.sell, 0));
</script>
<template>
<VirtualScrollTable :list="sortedArray" :itemHeight="33" :footerHeight="33" bottom="1rem">
<template #default="{ list }">
<thead>
<tr>
<SortableHeader v-bind="headerProps" sortKey="name">Item</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="quantity">Qty</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="buyUnit">Buy/unit</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="sellUnit">Sell/unit</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="buy">Buy total</SortableHeader>
<SortableHeader v-bind="headerProps" sortKey="sell">Sell total</SortableHeader>
</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>
<td class="text-right"><IskLabel :amount="r.data.buyUnit" :colored="false" /></td>
<td class="text-right"><IskLabel :amount="r.data.sellUnit" :colored="false" /></td>
<td class="text-right"><IskLabel :amount="r.data.buy" :colored="false" /></td>
<td class="text-right"><IskLabel :amount="r.data.sell" :colored="false" /></td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="4" class="font-semibold">Total</td>
<td class="text-right"><IskLabel :amount="totalBuy" :colored="false" /></td>
<td class="text-right"><IskLabel :amount="totalSell" :colored="false" /></td>
</tr>
</tfoot>
</template>
<template #empty>
<div class="text-center mt-4">
<span>No items found</span>
</div>
</template>
</VirtualScrollTable>
</template>
+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;
};
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 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;
+32
View File
@@ -0,0 +1,32 @@
import {marketApi} from '@/mammon';
import {getMarketTypes} from '../type';
export type AppraisalItemValue = {
typeID: number;
name: string;
quantity: number;
buy: number;
sell: number;
buyUnit: number;
sellUnit: number;
};
export const appraise = async (raw?: string, locationId?: number): Promise<AppraisalItemValue[]> => {
if (!raw?.trim()) {
return [];
}
const results = await marketApi.pastePrices(raw, locationId).then(r => r.data);
const names = await getMarketTypes(results.map(r => r.marketTypeId))
.then(types => new Map(types.map(t => [t.id, t.name])));
return results.map(r => ({
typeID: r.marketTypeId,
name: names.get(r.marketTypeId) ?? '',
quantity: r.quantity,
buy: r.buy,
sell: r.sell,
buyUnit: r.quantity ? r.buy / r.quantity : 0,
sellUnit: r.quantity ? r.sell / r.quantity : 0,
}));
};
+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()];
};
+5
View File
@@ -1,2 +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';
+2 -2
View File
@@ -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
View File
@@ -1,5 +1,6 @@
export * from './RegionalMarketCache';
export * from './history';
export * from './location';
export * from './order';
export * from './tax';
export * from './type';
+30
View File
@@ -0,0 +1,30 @@
import {universeApi} from '@/mammon/mammonService';
import type {MarketLocationResponse} from '@/generated/mammon';
export type MarketLocation = MarketLocationResponse;
const cache = new Map<number, MarketLocation>();
const defaultHubIds = [
60003760, // Jita IV - Moon 4 - Caldari Navy Assembly Plant
60008494, // Amarr VIII (Oris) - Emperor Family Academy
60011866, // Dodixie IX - Moon 20 - Federation Navy Assembly Plant
60004588, // Rens VI - Moon 8 - Brutor Tribe Treasury
60005686, // Hek VIII - Moon 12 - Boundless Creation Factory
];
const hubRank = (id: number) => {
const rank = defaultHubIds.indexOf(id);
return rank < 0 ? defaultHubIds.length : rank;
};
export const isDefaultHub = (id: number): boolean => defaultHubIds.includes(id);
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.toSorted((a, b) => hubRank(a.id) - hubRank(b.id));
};
@@ -0,0 +1,46 @@
<script setup lang="ts">
import {watchDebounced} from '@vueuse/core';
import {ref, watch} from 'vue';
import {SelectInput} from '@/components';
import {isDefaultHub, MarketLocation, searchMarketLocations} from './MarketLocation';
const modelValue = defineModel<MarketLocation>();
const search = ref(modelValue.value?.name ?? '');
const suggestions = ref<MarketLocation[]>([]);
watch(() => modelValue.value, v => {
search.value = v?.name ?? '';
});
watchDebounced(search, async value => {
const term = value.trim();
if (term.length < 3 || term === modelValue.value?.name) {
suggestions.value = [];
} else {
suggestions.value = await searchMarketLocations(term);
}
}, {debounce: 300});
</script>
<template>
<SelectInput v-model="modelValue" :items="suggestions" class="w-96">
<template #input>
<input type="text" v-model="search" placeholder="Search a market location…" />
</template>
<template #item="{ item }">
<div class="px-1 whitespace-nowrap overflow-hidden text-ellipsis" :class="{'text-emerald-400': isDefaultHub(item.id)}">
{{ item.name }} <span class="text-slate-300">({{ item.regionName }})</span>
</div>
</template>
</SelectInput>
</template>
<style scoped>
@reference "@/style.css";
input {
@apply w-full border-none bg-transparent block focus-visible:outline-none;
box-sizing: border-box;
}
</style>
+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;
+6
View File
@@ -15,6 +15,12 @@ import {routeNames} from '@/routes';
<RouterLink to="/market/acquisitions" class="tab">
<span>Acquisitions</span>
</RouterLink>
<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>
+33
View File
@@ -0,0 +1,33 @@
<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[]>([]);
const send = async () => result.value = await appraise(items.value);
</script>
<template>
<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 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">
<AppraisalResultTable :result="result" />
</div>
</template>
</template>
+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>
+4
View File
@@ -15,6 +15,8 @@ export const routeNames = {
listLedgerAcquisitions: 'list-ledger-acquisitions',
editRuleBook: 'edit-rule-book',
marketTypes: 'market-types',
appraise: 'appraise',
compareAppraisal: 'compare-appraisal',
about: 'about',
} as const;
@@ -39,6 +41,8 @@ export const routes: RouteRecordRaw[] = [
{path: 'types/:type?', name: routeNames.marketTypes, component: () => import('@/pages/market/TypeInfo.vue')},
{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')},