diff --git a/.storybook/fits.ts b/.storybook/fits.ts index 8c1cafe..2976a83 100644 --- a/.storybook/fits.ts +++ b/.storybook/fits.ts @@ -137,7 +137,7 @@ export const fullFits = [ { flag: 32, quantity: 1, type_id: 30836 }, { flag: 33, quantity: 1, type_id: 11578 }, { flag: 34, quantity: 1, type_id: 28756, charge: { type_id: 30488 } }, - { flag: 87, quantity: 5, type_id: 2456 }, + { flag: 87, quantity: 8, type_id: 2456 }, { flag: 92, quantity: 1, type_id: 31748 }, { flag: 93, quantity: 1, type_id: 31760 }, { flag: 94, quantity: 1, type_id: 31588 }, diff --git a/src/DroneBay/DroneBay.module.css b/src/DroneBay/DroneBay.module.css new file mode 100644 index 0000000..0a06a68 --- /dev/null +++ b/src/DroneBay/DroneBay.module.css @@ -0,0 +1,74 @@ +.droneBay { + background-color: #111111; + border: 1px solid #333333; + color: #c5c5c5; + font-size: 15px; + padding: 5px; + position: relative; +} + +.droneBayEntry { + display: flex; + height: 64px; + padding: 4px; +} +.droneBayEntry:hover { + background-color: #321d1d; +} + +.droneBayEntry > div { + margin-right: 8px; +} + +.droneBayEntry .middle { + flex-grow: 1; +} + +.droneBayEntry .amount { + line-height: 64px; + text-align: center; + width: 30px; +} + +.droneBayEntry .name { + line-height: 32px; +} + +.droneBayEntry .selected { + color: #909090; +} + +.droneBayEntry .close { + color: #909090; + cursor: pointer; + line-height: 64px; +} + +.droneBayEntrySelected { + background-color: #321d1d; + border: 1px solid #52281c; + cursor: pointer; + display: inline-block; + font-size: 10px; + height: 16px; + line-height: 16px; + margin-left: 2px; + text-align: center; + width: 16px; +} + +.open { + background-color: #7a3925; + border: 1px solid #a54b32; +} + +.droneBayEntrySelected:hover { + background-color: #d05c3b; + border: 1px solid #ee6d47; +} + +.active { + background-color: #7a3925; + border: 1px solid #a54b32; + color: white; +} diff --git a/src/DroneBay/DroneBay.stories.tsx b/src/DroneBay/DroneBay.stories.tsx new file mode 100644 index 0000000..1a4d3c0 --- /dev/null +++ b/src/DroneBay/DroneBay.stories.tsx @@ -0,0 +1,49 @@ +import type { Decorator, Meta, StoryObj } from "@storybook/react"; +import React from "react"; + +import { fullFit } from "../../.storybook/fits"; + +import { DogmaEngineProvider } from "../DogmaEngineProvider"; +import { EsiProvider } from "../EsiProvider"; +import { EveDataProvider } from "../EveDataProvider"; +import { ShipSnapshotProvider } from "../ShipSnapshotProvider"; +import { DroneBay } from "./"; + +const meta: Meta = { + component: DroneBay, + tags: ["autodocs"], + title: "Component/DroneBay", +}; + +export default meta; +type Story = StoryObj; + +const useShipSnapshotProvider: Decorator> = (Story, context) => { + const [skills, setSkills] = React.useState>({}); + + return ( + + + + +
+ +
+
+
+
+
+ ); +}; + +export const Default: Story = { + args: { + width: 730, + }, + decorators: [useShipSnapshotProvider], + parameters: { + snapshot: { + fit: fullFit, + }, + }, +}; diff --git a/src/DroneBay/DroneBay.tsx b/src/DroneBay/DroneBay.tsx new file mode 100644 index 0000000..8f0bd23 --- /dev/null +++ b/src/DroneBay/DroneBay.tsx @@ -0,0 +1,129 @@ +import clsx from "clsx"; +import React from "react"; + +import styles from "./DroneBay.module.css"; +import { CharAttribute, ShipAttribute } from "../ShipAttribute"; +import { ShipSnapshotContext, ShipSnapshotItem } from "../ShipSnapshotProvider"; +import { EveDataContext } from "../EveDataProvider"; + +const DroneBayEntrySelected = ({ + drone, + index, + isOpen, +}: { + drone: ShipSnapshotItem; + index: number; + isOpen: boolean; +}) => { + const snapshot = React.useContext(ShipSnapshotContext); + + const onClick = React.useCallback(() => { + snapshot.toggleDrones(drone.type_id, index + 1); + }, [snapshot, drone, index]); + + return ( +
+ {drone.state === "Active" && <>X} + {drone.state === "Passive" && <> } +
+ ); +}; + +const DroneBayEntry = ({ name, drones }: { name: string; drones: ShipSnapshotItem[] }) => { + const eveData = React.useContext(EveDataContext); + const snapshot = React.useContext(ShipSnapshotContext); + + const attributeDroneBandwidthUsedTotal = eveData.attributeMapping?.droneBandwidthUsedTotal || 0; + const attributeDroneActive = eveData.attributeMapping?.droneActive || 0; + const attributeDroneBandwidthUsed = eveData.attributeMapping?.droneBandwidthUsed || 0; + const attributeDroneBandwidth = eveData.attributeMapping?.droneBandwidth || 0; + const attributeMaxActiveDrones = eveData.attributeMapping?.maxActiveDrones || 0; + + const bandwidthUsed = snapshot.hull?.attributes?.get(attributeDroneBandwidthUsedTotal)?.value ?? 0; + const bandwidthAvailable = snapshot.hull?.attributes?.get(attributeDroneBandwidth)?.value ?? 0; + const dronesActive = snapshot.hull?.attributes?.get(attributeDroneActive)?.value ?? 0; + const maxDronesActive = snapshot.char?.attributes?.get(attributeMaxActiveDrones)?.value ?? 0; + const droneBandwidth = drones[0].attributes?.get(attributeDroneBandwidthUsed)?.value ?? 0; + const maxSelected = Math.max(0, Math.min(maxDronesActive, Math.floor(bandwidthAvailable / droneBandwidth))); + + let maxOpen = Math.max( + 0, + Math.min(maxDronesActive - dronesActive, Math.floor((bandwidthAvailable - bandwidthUsed) / droneBandwidth)), + ); + let index = 0; + + const dronesSelected = drones.slice(0, maxSelected); + + const onRemove = React.useCallback(() => { + snapshot.removeDrones(drones[0].type_id); + }, [snapshot, drones]); + + return ( +
+
{drones.length} x
+
+ +
+
+
{name}
+
+ Selected: + {Object.entries(dronesSelected).map(([key, drone]) => { + const isOpen = drone.state !== "Active" && maxOpen > 0; + if (isOpen) maxOpen--; + + return ; + })} +
+
+
+ X +
+
+ ); +}; + +export const DroneBay = () => { + const eveData = React.useContext(EveDataContext); + const snapshot = React.useContext(ShipSnapshotContext); + + const [drones, setDrones] = React.useState>({}); + + React.useEffect(() => { + if (snapshot === undefined || !snapshot.loaded || snapshot.items === undefined) return; + if (eveData === undefined || !eveData.loaded || eveData.typeIDs === undefined) return; + + /* Group drones by type_id */ + const dronesGrouped: Record = {}; + for (const drone of snapshot.items.filter((item) => item.flag == 87)) { + const name = eveData.typeIDs?.[drone.type_id].name ?? ""; + + if (dronesGrouped[name] === undefined) { + dronesGrouped[name] = []; + } + dronesGrouped[name].push(drone); + } + + setDrones(dronesGrouped); + }, [snapshot, eveData]); + + return ( +
+
+ Active drones: /{" "} + +
+ {Object.entries(drones) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([name, droneList]) => { + return ; + })} +
+ ); +}; diff --git a/src/DroneBay/index.ts b/src/DroneBay/index.ts new file mode 100644 index 0000000..401eb76 --- /dev/null +++ b/src/DroneBay/index.ts @@ -0,0 +1 @@ +export { DroneBay } from "./DroneBay"; diff --git a/src/ShipFitExtended/ShipFitExtended.module.css b/src/ShipFitExtended/ShipFitExtended.module.css index 30a9d4a..46bd829 100644 --- a/src/ShipFitExtended/ShipFitExtended.module.css +++ b/src/ShipFitExtended/ShipFitExtended.module.css @@ -47,3 +47,20 @@ display: inline-block; margin-left: 5px; } + +.droneBay { + cursor: pointer; + position: relative; + z-index: 100; +} +.droneBayOverlay { + display: none; + position: absolute; + bottom: 40px; + left: 10px; + width: 350px; + z-index: 101; +} +.droneBayVisible { + display: block; +} diff --git a/src/ShipFitExtended/ShipFitExtended.tsx b/src/ShipFitExtended/ShipFitExtended.tsx index 4b3729b..381ff60 100644 --- a/src/ShipFitExtended/ShipFitExtended.tsx +++ b/src/ShipFitExtended/ShipFitExtended.tsx @@ -6,8 +6,10 @@ import { ShipAttribute } from "../ShipAttribute"; import { ShipSnapshotContext } from "../ShipSnapshotProvider"; import styles from "./ShipFitExtended.module.css"; +import clsx from "clsx"; +import { DroneBay } from "../DroneBay"; -const CargoHold = () => { +const ShipCargoHold = () => { return (
@@ -24,22 +26,29 @@ const CargoHold = () => { ); }; -const DroneBay = () => { +const ShipDroneBay = () => { + const [isOpen, setIsOpen] = React.useState(false); + return ( -
-
- -
-
-
- + <> +
setIsOpen(!isOpen)} className={styles.droneBay}> +
+
-
- / +
+
+ +
+
+ / +
+
m3
-
m3
-
+
+ +
+ ); }; @@ -80,8 +89,8 @@ export const ShipFitExtended = () => {
- - + +
diff --git a/src/ShipSnapshotProvider/ShipSnapshotProvider.tsx b/src/ShipSnapshotProvider/ShipSnapshotProvider.tsx index bd1b09e..56bd707 100644 --- a/src/ShipSnapshotProvider/ShipSnapshotProvider.tsx +++ b/src/ShipSnapshotProvider/ShipSnapshotProvider.tsx @@ -71,6 +71,8 @@ interface ShipSnapshot { removeModule: (flag: number) => void; addCharge: (chargeTypeId: number) => void; removeCharge: (flag: number) => void; + toggleDrones: (typeId: number, active: number) => void; + removeDrones: (typeId: number) => void; changeHull: (typeId: number) => void; changeFit: (fit: EsiFit) => void; setItemState: (flag: number, state: string) => void; @@ -90,6 +92,8 @@ export const ShipSnapshotContext = React.createContext({ removeModule: () => {}, addCharge: () => {}, removeCharge: () => {}, + toggleDrones: () => {}, + removeDrones: () => {}, changeHull: () => {}, changeFit: () => {}, setItemState: () => {}, @@ -131,6 +135,8 @@ export const ShipSnapshotProvider = (props: ShipSnapshotProps) => { removeModule: () => {}, addCharge: () => {}, removeCharge: () => {}, + toggleDrones: () => {}, + removeDrones: () => {}, changeHull: () => {}, changeFit: () => {}, setItemState: () => {}, @@ -294,6 +300,68 @@ export const ShipSnapshotProvider = (props: ShipSnapshotProps) => { }); }, []); + const toggleDrones = React.useCallback((typeId: number, active: number) => { + setCurrentFit((oldFit: EsiFit | undefined) => { + if (oldFit === undefined) return undefined; + + /* Find the amount of drones in the current fit. */ + const count = oldFit.items + .filter((item) => item.flag === 87 && item.type_id === typeId) + .reduce((acc, item) => acc + item.quantity, 0); + if (count === 0) return oldFit; + + /* If we request the same amount of active than we had, assume we want to deactive the current. */ + const currentActive = oldFit.items + .filter((item) => item.flag === 87 && item.type_id === typeId && item.state === "Active") + .reduce((acc, item) => acc + item.quantity, 0); + if (currentActive === active) { + active = active - 1; + } + + /* Ensure we never have more active than available. */ + active = Math.min(count, active); + + /* Remove all drones of this type. */ + const newItems = oldFit.items.filter((item) => item.flag !== 87 || item.type_id !== typeId); + + /* Add the active drones. */ + if (active > 0) { + newItems.push({ + flag: 87, + type_id: typeId, + quantity: active, + state: "Active", + }); + } + + /* Add the passive drones. */ + if (active < count) { + newItems.push({ + flag: 87, + type_id: typeId, + quantity: count - active, + state: "Passive", + }); + } + + return { + ...oldFit, + items: newItems, + }; + }); + }, []); + + const removeDrones = React.useCallback((typeId: number) => { + setCurrentFit((oldFit: EsiFit | undefined) => { + if (oldFit === undefined) return undefined; + + return { + ...oldFit, + items: oldFit.items.filter((item) => item.flag !== 87 || item.type_id !== typeId), + }; + }); + }, []); + const changeHull = React.useCallback( (typeId: number) => { const hullName = eveData?.typeIDs?.[typeId].name; @@ -315,12 +383,14 @@ export const ShipSnapshotProvider = (props: ShipSnapshotProps) => { removeModule, addCharge, removeCharge, + toggleDrones, + removeDrones, changeHull, changeFit: setCurrentFit, setItemState, setName, })); - }, [addModule, removeModule, addCharge, removeCharge, changeHull, setItemState, setName]); + }, [addModule, removeModule, addCharge, removeCharge, toggleDrones, removeDrones, changeHull, setItemState, setName]); React.useEffect(() => { if (!dogmaEngine.loaded) return; diff --git a/src/index.ts b/src/index.ts index d530bab..8a71d34 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ export * from "./CalculationDetail"; export * from "./DogmaEngineProvider"; +export * from "./DroneBay"; export * from "./EsiCharacterSelection"; export * from "./EsiProvider"; export * from "./EveDataProvider";