feat(hardware): also show charges, and allow filtering on fitted modules (#64)

This commit is contained in:
Patric Stout
2024-03-03 13:13:48 +01:00
committed by GitHub
parent 9260537e3f
commit 0d4aecc666
4 changed files with 271 additions and 67 deletions

View File

@@ -6,9 +6,8 @@
position: relative;
width: 100%;
}
.listingContent {
height: calc(100% - 42px - 32px - 5px);
height: calc(100% - 42px - 32px - 5px - 24px - 5px);
overflow-y: auto;
padding-right: 20px;
}
@@ -29,7 +28,7 @@
.filter {
display: flex;
margin-bottom: 5px;
height: 37px;
}
.filter > span {
border-radius: 5px;
@@ -55,3 +54,37 @@
.filter > span.disabled:hover {
background-color: #111111;
}
.selectionHeader {
display: flex;
margin-bottom: 5px;
}
.selectionHeader > div {
background-color: #4f4f4f;
cursor: pointer;
flex: 1;
line-height: 24px;
text-align: center;
}
.selectionHeader > div:hover {
background-color: #6c6c6c;
}
.selectionHeader > div:first-child {
border-bottom-left-radius: 25px;
margin-right: 5px;
}
.selectionHeader > div:last-child {
border-bottom-right-radius: 25px;
margin-left: 5px;
}
.selectionHeader > div.selected {
background-color: #7a7a7a;
}
.collapsed {
display: none;
}
.moduleChargeIcon {
border-top-left-radius: 50%;
}

View File

@@ -1,7 +1,11 @@
import type { Decorator, Meta, StoryObj } from "@storybook/react";
import React from "react";
import { DogmaEngineProvider } from "../DogmaEngineProvider";
import { EsiProvider } from "../EsiProvider";
import { EveDataProvider } from "../EveDataProvider";
import { fullFit } from "../../.storybook/fits";
import { ShipSnapshotProvider } from "../ShipSnapshotProvider";
import { HardwareListing } from "./";
@@ -14,16 +18,29 @@ const meta: Meta<typeof HardwareListing> = {
export default meta;
type Story = StoryObj<typeof HardwareListing>;
const withEveDataProvider: Decorator<Record<string, never>> = (Story) => {
const useShipSnapshotProvider: Decorator<Record<string, never>> = (Story, context) => {
const [skills, setSkills] = React.useState<Record<string, number>>({});
return (
<EveDataProvider>
<div style={{ height: "400px" }}>
<Story />
</div>
<EsiProvider setSkills={setSkills}>
<DogmaEngineProvider>
<ShipSnapshotProvider {...context.parameters.snapshot} skills={skills}>
<div style={{ width: context.args.width, height: context.args.width }}>
<Story />
</div>
</ShipSnapshotProvider>
</DogmaEngineProvider>
</EsiProvider>
</EveDataProvider>
);
};
export const Default: Story = {
decorators: [withEveDataProvider],
decorators: [useShipSnapshotProvider],
parameters: {
snapshot: {
fit: fullFit,
},
},
};

View File

@@ -9,11 +9,18 @@ import { TreeListing, TreeHeader, TreeLeaf } from "../TreeListing";
import styles from "./HardwareListing.module.css";
interface ModuleCharge {
typeId: number;
name: string;
chargeGroupIDs: number[];
chargeSize: number;
}
interface ListingItem {
name: string;
meta: number;
typeId: number;
slotType: ShipSnapshotSlotsType | "dronebay";
slotType: ShipSnapshotSlotsType | "dronebay" | "charge";
}
interface ListingGroup {
@@ -24,7 +31,16 @@ interface ListingGroup {
items: ListingItem[];
}
const ModuleGroup = (props: { level: number; group: ListingGroup }) => {
interface Filter {
lowslot: boolean;
medslot: boolean;
hislot: boolean;
rig_subsystem: boolean;
drone: boolean;
moduleWithCharge: ModuleCharge | undefined;
}
const ModuleGroup = (props: { level: number; group: ListingGroup; hideGroup?: boolean }) => {
const shipSnapShot = React.useContext(ShipSnapshotContext);
const getChildren = React.useCallback(() => {
@@ -33,14 +49,19 @@ const ModuleGroup = (props: { level: number; group: ListingGroup }) => {
{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)}
/>
);
if (item.slotType === "charge") {
return <TreeLeaf key={item.typeId} level={2} content={item.name} onClick={() => {}} />;
} else {
const slotType = item.slotType;
return (
<TreeLeaf
key={item.typeId}
level={2}
content={item.name}
onClick={() => shipSnapShot.addModule(item.typeId, slotType)}
/>
);
}
})}
{Object.keys(props.group.groups)
.sort(
@@ -55,6 +76,10 @@ const ModuleGroup = (props: { level: number; group: ListingGroup }) => {
);
}, [props, shipSnapShot]);
if (props.hideGroup) {
return <TreeListing level={props.level} getChildren={getChildren} />;
}
const header = (
<TreeHeader
icon={props.group.iconID === undefined ? "" : `${defaultDataUrl}icons/${props.group.iconID}.png`}
@@ -69,6 +94,7 @@ const ModuleGroup = (props: { level: number; group: ListingGroup }) => {
*/
export const HardwareListing = () => {
const eveData = React.useContext(EveDataContext);
const shipSnapShot = React.useContext(ShipSnapshotContext);
const [moduleGroups, setModuleGroups] = React.useState<ListingGroup>({
name: "Modules",
@@ -76,14 +102,65 @@ export const HardwareListing = () => {
groups: {},
items: [],
});
const [chargeGroups, setChageGroups] = React.useState<ListingGroup>({
name: "Charges",
meta: 0,
groups: {},
items: [],
});
const [search, setSearch] = React.useState<string>("");
const [filter, setFilter] = React.useState({
const [filter, setFilter] = React.useState<Filter>({
lowslot: false,
medslot: false,
hislot: false,
rig_subsystem: false,
drone: false,
moduleWithCharge: undefined,
});
const [selection, setSelection] = React.useState<"modules" | "charges">("modules");
const [modulesWithCharges, setModulesWithCharges] = React.useState<ModuleCharge[]>([]);
React.useEffect(() => {
if (!eveData.loaded) return;
if (!shipSnapShot.loaded || shipSnapShot.items === undefined) return;
/* Iterate all items to check if they have a charge. */
const newModulesWithCharges: ModuleCharge[] = [];
const seenModules = new Set<number>();
for (const item of shipSnapShot.items) {
const chargeGroup1 = item.attributes.get(eveData?.attributeMapping?.chargeGroup1 || 0)?.value;
const chargeGroup2 = item.attributes.get(eveData?.attributeMapping?.chargeGroup2 || 0)?.value;
const chargeGroup3 = item.attributes.get(eveData?.attributeMapping?.chargeGroup3 || 0)?.value;
const chargeGroup4 = item.attributes.get(eveData?.attributeMapping?.chargeGroup4 || 0)?.value;
const chargeGroup5 = item.attributes.get(eveData?.attributeMapping?.chargeGroup5 || 0)?.value;
const chargeGroupIDs: number[] = [chargeGroup1, chargeGroup2, chargeGroup3, chargeGroup4, chargeGroup5].filter(
(x): x is number => x !== undefined,
);
if (chargeGroupIDs.length === 0) continue;
if (seenModules.has(item.type_id)) continue;
seenModules.add(item.type_id);
newModulesWithCharges.push({
typeId: item.type_id,
name: eveData?.typeIDs?.[item.type_id].name ?? "Unknown",
chargeGroupIDs,
chargeSize: item.attributes.get(eveData?.attributeMapping?.chargeSize || 0)?.value ?? -1,
});
}
setModulesWithCharges(newModulesWithCharges);
/* Reset the filter, as the ship is changed. */
setFilter({
...filter,
moduleWithCharge: undefined,
});
/* Filter should not be part of the dependency array. */
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [shipSnapShot, eveData, setFilter]);
React.useEffect(() => {
if (!eveData.loaded) return;
@@ -94,43 +171,79 @@ export const HardwareListing = () => {
groups: {},
items: [],
};
const newChargeGroups: ListingGroup = {
name: "Charges",
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)
/* Modules (7), Charges (8), Drones (18), Subsystems (32), and Structures (66) */
if (
module.categoryID !== 7 &&
module.categoryID !== 8 &&
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";
let slotType: ShipSnapshotSlotsType | "dronebay" | "charge" | undefined;
if (module.categoryID !== 8) {
slotType = 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 || filter.medslot || filter.hislot || filter.rig_subsystem || filter.drone) {
if (slotType === "lowslot" && !filter.lowslot) continue;
if (slotType === "medslot" && !filter.medslot) continue;
if (slotType === "hislot" && !filter.hislot) continue;
if ((slotType === "rig" || slotType === "subsystem") && !filter.rig_subsystem) continue;
if (module.categoryID === 18 && !filter.drone) continue;
}
} else {
if (filter.moduleWithCharge !== undefined) {
/* If the module has size restrictions, ensure the charge matches. */
const chargeSize =
eveData.typeDogma?.[typeId]?.dogmaAttributes.find(
(attr) => attr.attributeID === eveData.attributeMapping?.chargeSize,
)?.value ?? -1;
if (filter.moduleWithCharge.chargeSize !== -1 && chargeSize !== filter.moduleWithCharge.chargeSize) continue;
for (const chargeGroupID of filter.moduleWithCharge.chargeGroupIDs) {
if (module.groupID !== chargeGroupID) continue;
slotType = "charge";
break;
}
})
.filter((slot) => slot !== undefined)[0];
if (module.categoryID === 18) {
slotType = "dronebay";
}
if (slotType === undefined) continue;
if (filter.lowslot || filter.medslot || filter.hislot || filter.rig_subsystem || filter.drone) {
if (slotType === "lowslot" && !filter.lowslot) continue;
if (slotType === "medslot" && !filter.medslot) continue;
if (slotType === "hislot" && !filter.hislot) continue;
if ((slotType === "rig" || slotType === "subsystem") && !filter.rig_subsystem) continue;
if (module.categoryID === 18 && !filter.drone) continue;
if (slotType === undefined) continue;
} else {
slotType = "charge";
}
}
if (search !== "" && !module.name.toLowerCase().includes(search.toLowerCase())) continue;
@@ -166,8 +279,12 @@ export const HardwareListing = () => {
if (module.categoryID === 66) marketGroups.push(477);
/* Build up the tree till the find the leaf node. */
let groupTree = newModuleGroups;
let groupTree = module.categoryID === 8 ? newChargeGroups : newModuleGroups;
for (const group of marketGroups.reverse()) {
if (module.categoryID === 8 && filter.moduleWithCharge !== undefined && group >= 0) {
continue;
}
if (groupTree.groups[group] === undefined) {
let name;
let meta;
@@ -219,6 +336,7 @@ export const HardwareListing = () => {
}
setModuleGroups(newModuleGroups);
setChageGroups(newChargeGroups);
}, [eveData, search, filter]);
return (
@@ -226,7 +344,7 @@ export const HardwareListing = () => {
<div className={styles.topbar}>
<input type="text" placeholder="Search" value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<div className={styles.filter}>
<div className={clsx(styles.filter, { [styles.collapsed]: selection !== "modules" })}>
<span
className={clsx({ [styles.selected]: filter.lowslot })}
onClick={() => setFilter({ ...filter, lowslot: !filter.lowslot })}
@@ -258,13 +376,47 @@ export const HardwareListing = () => {
<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 className={clsx(styles.filter, { [styles.collapsed]: selection !== "charges" })}>
{modulesWithCharges
.sort((a, b) => a.name.localeCompare(b.name))
.map((chargeGroup) => {
return (
<span
key={chargeGroup.typeId}
className={clsx({ [styles.selected]: filter.moduleWithCharge?.typeId === chargeGroup.typeId })}
onClick={() =>
setFilter({
...filter,
moduleWithCharge: filter.moduleWithCharge?.typeId === chargeGroup.typeId ? undefined : chargeGroup,
})
}
>
<img
src={`https://images.evetech.net/types/${chargeGroup.typeId}/icon?size=64`}
height={32}
width={32}
alt=""
className={styles.moduleChargeIcon}
title={chargeGroup.name}
/>
</span>
);
})}
</div>
<div className={styles.selectionHeader}>
<div onClick={() => setSelection("modules")} className={clsx({ [styles.selected]: selection === "modules" })}>
Modules
</div>
<div onClick={() => setSelection("charges")} className={clsx({ [styles.selected]: selection === "charges" })}>
Charges
</div>
</div>
<div className={clsx(styles.listingContent, { [styles.collapsed]: selection !== "modules" })}>
<ModuleGroup key="modules" level={0} group={moduleGroups} hideGroup={true} />
</div>
<div className={clsx(styles.listingContent, { [styles.collapsed]: selection !== "charges" })}>
<ModuleGroup key="charges" level={0} group={chargeGroups} hideGroup={true} />
</div>
</div>
);
};

View File

@@ -87,11 +87,11 @@ export const TreeLeaf = (props: {
*/
export const TreeListing = (props: {
level: number;
header: React.ReactNode;
header?: React.ReactNode;
height?: number;
getChildren: () => React.ReactNode;
}) => {
const [expanded, setExpanded] = React.useState(false);
const [expanded, setExpanded] = React.useState(props.header === undefined);
const stylesHeader = styles[`header${props.level}`];
const stylesContent = styles[`content${props.level}`];
@@ -108,17 +108,19 @@ export const TreeListing = (props: {
return (
<div>
<TreeContext.Provider value={{ size: height }}>
<div
style={style}
className={clsx(styles.header, styles.headerHover, stylesHeader)}
onClick={() => setExpanded((current) => !current)}
>
<span>
<Icon name={expanded ? "menu-expand" : "menu-collapse"} size={12} />
</span>
{props.header}
</div>
<div className={clsx(styles.content, stylesContent)}>{children}</div>
{props.header !== undefined && (
<div
style={style}
className={clsx(styles.header, styles.headerHover, stylesHeader)}
onClick={() => setExpanded((current) => !current)}
>
<span>
<Icon name={expanded ? "menu-expand" : "menu-collapse"} size={12} />
</span>
{props.header}
</div>
)}
<div className={clsx(stylesContent, { [styles.content]: props.header !== undefined })}>{children}</div>
</TreeContext.Provider>
</div>
);