feat: list all modules for adding them to fits (#40)

This commit is contained in:
Patric Stout
2023-12-09 12:09:03 +01:00
committed by GitHub
parent d9063bfc32
commit e60aa71230
11 changed files with 418 additions and 39 deletions

View File

@@ -21,6 +21,7 @@ export interface TypeID {
published: boolean,
factionID?: number,
marketGroupID?: number,
metaGroupID?: number,
capacity?: number,
mass?: number,
radius?: number,
@@ -36,6 +37,7 @@ export interface GroupID {
export interface MarketGroup {
name: string,
parentGroupID?: number,
iconID?: number,
}
export interface DogmaAttribute {

View File

@@ -263,12 +263,13 @@ export const esf = $root.esf = (() => {
TypeID.prototype.groupID = 0;
TypeID.prototype.categoryID = 0;
TypeID.prototype.published = false;
TypeID.prototype.factionID = 0;
TypeID.prototype.marketGroupID = 0;
TypeID.prototype.capacity = 0;
TypeID.prototype.mass = 0;
TypeID.prototype.radius = 0;
TypeID.prototype.volume = 0;
TypeID.prototype.factionID = undefined;
TypeID.prototype.marketGroupID = undefined;
TypeID.prototype.metaGroupID = undefined;
TypeID.prototype.capacity = undefined;
TypeID.prototype.mass = undefined;
TypeID.prototype.radius = undefined;
TypeID.prototype.volume = undefined;
TypeID.decode = function decode(r, l) {
if (!(r instanceof $Reader))
@@ -302,18 +303,22 @@ export const esf = $root.esf = (() => {
break;
}
case 7: {
m.capacity = r.float();
m.metaGroupID = r.int32();
break;
}
case 8: {
m.mass = r.float();
m.capacity = r.float();
break;
}
case 9: {
m.radius = r.float();
m.mass = r.float();
break;
}
case 10: {
m.radius = r.float();
break;
}
case 11: {
m.volume = r.float();
break;
}
@@ -511,7 +516,8 @@ export const esf = $root.esf = (() => {
}
MarketGroup.prototype.name = "";
MarketGroup.prototype.parentGroupID = 0;
MarketGroup.prototype.parentGroupID = undefined;
MarketGroup.prototype.iconID = undefined;
MarketGroup.decode = function decode(r, l) {
if (!(r instanceof $Reader))
@@ -528,6 +534,10 @@ export const esf = $root.esf = (() => {
m.parentGroupID = r.int32();
break;
}
case 3: {
m.iconID = r.int32();
break;
}
default:
r.skipType(t & 7);
break;
@@ -738,14 +748,14 @@ export const esf = $root.esf = (() => {
DogmaEffect.prototype.isWarpSafe = false;
DogmaEffect.prototype.propulsionChance = false;
DogmaEffect.prototype.rangeChance = false;
DogmaEffect.prototype.dischargeAttributeID = 0;
DogmaEffect.prototype.durationAttributeID = 0;
DogmaEffect.prototype.rangeAttributeID = 0;
DogmaEffect.prototype.falloffAttributeID = 0;
DogmaEffect.prototype.trackingSpeedAttributeID = 0;
DogmaEffect.prototype.fittingUsageChanceAttributeID = 0;
DogmaEffect.prototype.resistanceAttributeID = 0;
DogmaEffect.prototype.modifierInfo = emptyArray;
DogmaEffect.prototype.dischargeAttributeID = undefined;
DogmaEffect.prototype.durationAttributeID = undefined;
DogmaEffect.prototype.rangeAttributeID = undefined;
DogmaEffect.prototype.falloffAttributeID = undefined;
DogmaEffect.prototype.trackingSpeedAttributeID = undefined;
DogmaEffect.prototype.fittingUsageChanceAttributeID = undefined;
DogmaEffect.prototype.resistanceAttributeID = undefined;
DogmaEffect.prototype.modifierInfo = undefined;
DogmaEffect.decode = function decode(r, l) {
if (!(r instanceof $Reader))
@@ -855,11 +865,11 @@ export const esf = $root.esf = (() => {
ModifierInfo.prototype.domain = 0;
ModifierInfo.prototype.func = 0;
ModifierInfo.prototype.modifiedAttributeID = 0;
ModifierInfo.prototype.modifyingAttributeID = 0;
ModifierInfo.prototype.operation = 0;
ModifierInfo.prototype.groupID = 0;
ModifierInfo.prototype.skillTypeID = 0;
ModifierInfo.prototype.modifiedAttributeID = undefined;
ModifierInfo.prototype.modifyingAttributeID = undefined;
ModifierInfo.prototype.operation = undefined;
ModifierInfo.prototype.groupID = undefined;
ModifierInfo.prototype.skillTypeID = undefined;
ModifierInfo.decode = function decode(r, l) {
if (!(r instanceof $Reader))

View File

@@ -0,0 +1,57 @@
.listing {
background-color: #111111;
color: #c5c5c5;
font-size: 15px;
height: 100%;
position: relative;
width: 100%;
}
.listingContent {
height: calc(100% - 42px - 32px - 5px);
overflow-y: auto;
padding-right: 20px;
}
.topbar {
display: flex;
}
.topbar > input {
background-color: #1d1d1d;
color: #c5c5c5;
flex: 1;
height: 24px;
line-height: 24px;
margin: 6px 0;
padding-left: 6px;
}
.filter {
display: flex;
margin-bottom: 5px;
}
.filter > span {
border-radius: 5px;
cursor: pointer;
display: inline-block;
height: 32px;
line-height: 32px;
text-align: center;
user-select: none;
width: 32px;
}
.filter > span:hover {
background-color: #4f4f4f;
}
.filter > span.selected {
background-color: #7a7a7a;
}
.filter > span.disabled {
color: #7a7a7a;
cursor: default;
}
.filter > span.disabled:hover {
background-color: #111111;
}

View File

@@ -0,0 +1,29 @@
import type { Decorator, Meta, StoryObj } from '@storybook/react';
import React from "react";
import { EveDataProvider } from '../EveDataProvider';
import { HardwareListing } from './';
const meta: Meta<typeof HardwareListing> = {
component: HardwareListing,
tags: ['autodocs'],
title: 'Component/HardwareListing',
};
export default meta;
type Story = StoryObj<typeof HardwareListing>;
const withEveDataProvider: Decorator<Record<string, never>> = (Story) => {
return (
<EveDataProvider>
<div style={{height: "400px"}}>
<Story />
</div>
</EveDataProvider>
);
}
export const Default: Story = {
decorators: [withEveDataProvider],
};

View File

@@ -0,0 +1,219 @@
import clsx from "clsx";
import React from "react";
import { defaultDataUrl } from "../settings";
import { EveDataContext } from "../EveDataProvider";
import { Icon } from "../Icon";
import { ShipSnapshotContext, ShipSnapshotSlotsType } from "../ShipSnapshotProvider";
import { TreeListing, TreeHeader, TreeLeaf } from "../TreeListing";
import styles from "./HardwareListing.module.css";
interface ListingItem {
name: string;
meta: number;
typeId: number;
slotType: ShipSnapshotSlotsType | "dronebay";
}
interface ListingGroup {
name: string;
meta: number;
iconID?: number;
groups: Record<string, ListingGroup>;
items: ListingItem[];
}
const ModuleGroup = (props: { level: number, group: ListingGroup }) => {
const shipSnapShot = React.useContext(ShipSnapshotContext);
const getChildren = React.useCallback(() => {
return <>
{props.group.items.sort((a, b) => a.meta - b.meta || a.name.localeCompare(b.name)).map((item) => {
return <TreeLeaf key={item.typeId} level={2} content={item.name} onClick={() => shipSnapShot.addModule(item.typeId, item.slotType)} />;
})}
{Object.keys(props.group.groups).sort((a, b) => props.group.groups[a].meta - props.group.groups[b].meta || props.group.groups[a].name.localeCompare(props.group.groups[b].name)).map((groupId) => {
return <ModuleGroup key={groupId} level={props.level + 1} group={props.group.groups[groupId]} />
})}
</>;
}, [props, shipSnapShot]);
const header = <TreeHeader icon={props.group.iconID === undefined ? "" : `${defaultDataUrl}icons/${props.group.iconID}.png`} text={props.group.name} />;
return <TreeListing level={props.level} header={header} getChildren={getChildren} />;
};
/**
* Show all the modules you can fit to a ship.
*/
export const HardwareListing = () => {
const eveData = React.useContext(EveDataContext);
const [moduleGroups, setModuleGroups] = React.useState<ListingGroup>({
name: "Modules",
meta: 0,
groups: {},
items: [],
});
const [search, setSearch] = React.useState<string>("");
const [filter, setFilter] = React.useState({
lowslot: false,
medslot: false,
hislot: false,
rig_subsystem: false,
drone: false,
});
React.useEffect(() => {
if (!eveData.loaded) return;
const newModuleGroups: ListingGroup = {
name: "Modules",
meta: 0,
groups: {},
items: [],
};
for (const typeId in eveData.typeIDs) {
const module = eveData.typeIDs[typeId];
/* Modules (7), Drones (18), Subsystems (32), and Structures (66) */
if (module.categoryID !== 7 && module.categoryID !== 18 && module.categoryID !== 32 && module.categoryID !== 66) continue;
if (module.marketGroupID === undefined) continue;
if (!module.published) continue;
let slotType: ShipSnapshotSlotsType | "dronebay" | undefined = eveData.typeDogma?.[typeId]?.dogmaEffects.map((effect) => {
switch (effect.effectID) {
case 11: return "lowslot";
case 13: return "medslot";
case 12: return "hislot";
case 2663: return "rig";
case 3772: return "subsystem";
}
}).filter((slot) => slot !== undefined)[0];
if (module.categoryID === 18) {
slotType = "dronebay";
}
if (slotType === undefined) continue;
if (filter.lowslot && slotType !== "lowslot") continue;
if (filter.medslot && slotType !== "medslot") continue;
if (filter.hislot && slotType !== "hislot") continue;
if (filter.rig_subsystem && slotType !== "rig" && slotType !== "subsystem") continue;
if (filter.drone && module.categoryID !== 18) continue;
if (search !== "" && !module.name.toLowerCase().includes(search.toLowerCase())) continue;
const marketGroups: number[] = [];
switch (module.metaGroupID) {
case 3: // Storyline
case 4: // Faction
marketGroups.push(-1);
break;
case 5: // Officer
marketGroups.push(-2);
break;
case 6: // Deadspace
marketGroups.push(-3);
break;
}
/* Construct the tree of the module's market groups. */
let marketGroup: number | undefined = module.marketGroupID;
while (marketGroup !== undefined) {
marketGroups.push(marketGroup);
marketGroup = eveData.marketGroups?.[marketGroup].parentGroupID;
}
/* Remove the root group. */
marketGroups.pop();
/* Put Drones and Structures in their own group. */
if (module.categoryID === 18) marketGroups.push(157);
if (module.categoryID === 66) marketGroups.push(477);
/* Build up the tree till the find the leaf node. */
let groupTree = newModuleGroups;
for (const group of marketGroups.reverse()) {
if (groupTree.groups[group] === undefined) {
let name;
let meta;
let iconID = undefined;
switch (group) {
case -1:
name = "Faction & Storyline";
iconID = 24146;
meta = 3;
break;
case -2:
name = "Officer";
iconID = 24149;
meta = 5;
break;
case -3:
name = "Deadspace";
iconID = 24148;
meta = 6;
break;
default:
name = eveData.marketGroups?.[group].name ?? "Unknown group";
meta = 1;
iconID = eveData.marketGroups?.[group].iconID;
break;
}
groupTree.groups[group] = {
name,
meta,
iconID,
groups: {},
items: [],
};
}
groupTree = groupTree.groups[group];
}
groupTree.items.push({
name: module.name,
meta: module.metaGroupID ?? 0,
typeId: parseInt(typeId),
slotType,
});
}
setModuleGroups(newModuleGroups);
}, [eveData, search, filter]);
return <div className={styles.listing}>
<div className={styles.topbar}>
<input type="text" placeholder="Search" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<div className={styles.filter}>
<span className={clsx({[styles.selected]: filter.lowslot})} onClick={() => setFilter({...filter, lowslot: !filter.lowslot})}>
<Icon name="fitting-lowslot" size={32} title="Filter: Low Slot" />
</span>
<span className={clsx({[styles.selected]: filter.medslot})} onClick={() => setFilter({...filter, medslot: !filter.medslot})}>
<Icon name="fitting-medslot" size={32} title="Filter: Mid Slot" />
</span>
<span className={clsx({[styles.selected]: filter.hislot})} onClick={() => setFilter({...filter, hislot: !filter.hislot})}>
<Icon name="fitting-hislot" size={32} title="Filter: High Slot" />
</span>
<span className={clsx({[styles.selected]: filter.rig_subsystem})} onClick={() => setFilter({...filter, rig_subsystem: !filter.rig_subsystem})}>
<Icon name="fitting-rig-subsystem" size={32} title="Filter: Rig & Subsystem Slots" />
</span>
<span className={clsx({[styles.selected]: filter.drone})} onClick={() => setFilter({...filter, drone: !filter.drone})}>
<Icon name="fitting-drones" size={32} title="Filter: Drones" />
</span>
</div>
<div className={styles.listingContent}>
{Object.keys(moduleGroups.groups).sort((a, b) => moduleGroups.groups[a].name.localeCompare(moduleGroups.groups[b].name)).map((groupId) => {
return <ModuleGroup key={groupId} level={1} group={moduleGroups.groups[groupId]} />
})}
</div>
</div>
};

View File

@@ -0,0 +1 @@
export { HardwareListing } from "./HardwareListing";

View File

@@ -11,8 +11,13 @@ const iconMapping = {
"fitting-alliance": "texture/classes/fitting/taballiancefits.png",
"fitting-character": "texture/windowicons/member.png",
"fitting-corporation": "texture/windowicons/corporation.png",
"fitting-drones": "texture/classes/fitting/filtericondrones.png",
"fitting-hislot": "texture/classes/fitting/filtericonhighslot.png",
"fitting-hull": "texture/classes/fitting/tabfittings.png",
"fitting-local": "texture/windowicons/note.png",
"fitting-lowslot": "texture/classes/fitting/filtericonlowslot.png",
"fitting-medslot": "texture/classes/fitting/filtericonmediumslot.png",
"fitting-rig-subsystem": "texture/classes/fitting/filtericonrigslot.png",
"hull-hp": "texture/classes/fitting/statsicons/structurehp.png",
"hull-repair-rate": "texture/classes/fitting/statsicons/hullrepairrate.png",
"inertia-modifier": "texture/classes/fitting/statsicons/inertiamodifier.png",

View File

@@ -57,6 +57,7 @@ interface ShipSnapshot {
fit?: EsiFit;
addModule: (typeId: number, slot: ShipSnapshotSlotsType | "dronebay") => void;
changeHull: (typeId: number) => void;
changeFit: (fit: EsiFit) => void;
setItemState: (flag: number, state: string) => void;
@@ -71,11 +72,20 @@ export const ShipSnapshotContext = React.createContext<ShipSnapshot>({
"subsystem": 0,
"rig": 0,
},
addModule: () => {},
changeHull: () => {},
changeFit: () => {},
setItemState: () => {},
});
const slotStart: Record<ShipSnapshotSlotsType, number> = {
"hislot": 27,
"medslot": 19,
"lowslot": 11,
"subsystem": 125,
"rig": 92,
};
export interface ShipSnapshotProps {
/** Children that can use this provider. */
children: React.ReactNode;
@@ -99,6 +109,7 @@ export const ShipSnapshotProvider = (props: ShipSnapshotProps) => {
"subsystem": 0,
"rig": 0,
},
addModule: () => {},
changeHull: () => {},
changeFit: () => {},
setItemState: () => {},
@@ -107,8 +118,6 @@ export const ShipSnapshotProvider = (props: ShipSnapshotProps) => {
const dogmaEngine = React.useContext(DogmaEngineContext);
const setItemState = React.useCallback((flag: number, state: string) => {
if (currentFit === undefined) return;
setCurrentFit((oldFit: EsiFit | undefined) => {
if (oldFit === undefined) return undefined;
@@ -126,7 +135,42 @@ export const ShipSnapshotProvider = (props: ShipSnapshotProps) => {
}),
};
})
}, [currentFit]);
}, []);
const addModule = React.useCallback((typeId: number, slot: ShipSnapshotSlotsType | "dronebay") => {
setCurrentFit((oldFit: EsiFit | undefined) => {
if (oldFit === undefined) return undefined;
let flag = 0;
/* Find the first free slot for that slot-type. */
if (slot !== "dronebay") {
for (let i = slotStart[slot]; i < slotStart[slot] + shipSnapshot.slots[slot]; i++) {
if (oldFit.items.find((item) => item.flag === i) !== undefined) continue;
flag = i;
break;
}
} else {
flag = 87;
}
/* Couldn't find a free slot. */
if (flag === 0) return oldFit;
return {
...oldFit,
items: [
...oldFit.items,
{
flag: flag,
type_id: typeId,
quantity: 1,
}
],
};
})
}, [shipSnapshot.slots]);
const changeHull = React.useCallback((typeId: number) => {
setCurrentFit({
@@ -137,6 +181,16 @@ export const ShipSnapshotProvider = (props: ShipSnapshotProps) => {
})
}, []);
React.useEffect(() => {
setShipSnapshot((oldSnapshot) => ({
...oldSnapshot,
addModule,
changeHull,
changeFit: setCurrentFit,
setItemState,
}));
}, [addModule, changeHull, setItemState]);
React.useEffect(() => {
if (!dogmaEngine.loaded) return;
if (currentFit === undefined || props.skills === undefined) return;
@@ -164,17 +218,17 @@ export const ShipSnapshotProvider = (props: ShipSnapshotProps) => {
slots.lowslot += item.attributes.get(eveData?.attributeMapping?.lowSlotModifier || 0)?.value || 0;
}
setShipSnapshot({
loaded: true,
hull: snapshot.hull,
items: snapshot.items,
slots,
fit: currentFit,
changeHull,
changeFit: setCurrentFit,
setItemState,
setShipSnapshot((oldSnapshot) => {
return {
...oldSnapshot,
loaded: true,
hull: snapshot.hull,
items: snapshot.items,
slots,
fit: currentFit,
};
});
}, [eveData, dogmaEngine, currentFit, props.skills, changeHull, setItemState]);
}, [eveData, dogmaEngine, currentFit, props.skills]);
React.useEffect(() => {
setCurrentFit(props.fit);

View File

@@ -1,8 +1,8 @@
.header {
display: flex;
height: var(--height);
padding: 2px 0;
line-height: var(--height);
padding: 2px 0;
user-select: none;
}
.header > span {
@@ -18,12 +18,13 @@
.headerText {
flex: 1;
white-space: nowrap;
}
.headerAction {
cursor: pointer;
opacity: 0.5;
margin-right: 4px;
opacity: 0.5;
}
.headerAction:hover {
opacity: 1.0;

View File

@@ -6,6 +6,7 @@ export * from './EveDataProvider';
export * from './EveShipFitHash';
export * from './EveShipFitLink';
export * from './FormatEftToEsi';
export * from './HardwareListing';
export * from './HullListing';
export * from './Icon';
export * from './ShipAttribute';

View File

@@ -1 +1 @@
export const defaultDataUrl = "https://data.eveship.fit/v4.1-20231115/";
export const defaultDataUrl = "https://data.eveship.fit/v6.1-20231115/";