feat(hardware): also show charges, and allow filtering on fitted modules (#64)
This commit is contained in:
@@ -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%;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user