From aa76928b5f72033872600a0fd9c1ab005b3a46d5 Mon Sep 17 00:00:00 2001 From: Patric Stout Date: Sun, 10 Dec 2023 12:42:46 +0100 Subject: [PATCH] chore: rework ShipFit to use SVGs for better pixel-perfect rendering (#41) --- src/ShipFit/FitLink.tsx | 2 +- src/ShipFit/Hull.tsx | 4 +- src/ShipFit/RadialMenu.tsx | 48 ++++ src/ShipFit/RingInner.tsx | 23 ++ src/ShipFit/RingOuter.tsx | 17 ++ src/ShipFit/RingTop.tsx | 19 ++ src/ShipFit/ShipFit.module.css | 211 +++++++++--------- src/ShipFit/ShipFit.stories.tsx | 8 +- src/ShipFit/ShipFit.tsx | 95 ++++---- src/ShipFit/Slot.tsx | 46 +++- .../ShipFitExtended.module.css | 5 +- .../ShipFitExtended.stories.tsx | 8 +- src/ShipFitExtended/ShipFitExtended.tsx | 16 +- .../ShipSnapshotProvider.tsx | 4 +- 14 files changed, 315 insertions(+), 191 deletions(-) create mode 100644 src/ShipFit/RadialMenu.tsx create mode 100644 src/ShipFit/RingInner.tsx create mode 100644 src/ShipFit/RingOuter.tsx create mode 100644 src/ShipFit/RingTop.tsx diff --git a/src/ShipFit/FitLink.tsx b/src/ShipFit/FitLink.tsx index 57e18a9..0b5efc6 100644 --- a/src/ShipFit/FitLink.tsx +++ b/src/ShipFit/FitLink.tsx @@ -50,7 +50,7 @@ export const FitLink = () => { onClick: isRemoteViewer ? undefined : linkPropsClick, }; - return
+ return
{ } return
-
- -
+
} diff --git a/src/ShipFit/RadialMenu.tsx b/src/ShipFit/RadialMenu.tsx new file mode 100644 index 0000000..f6e1b6c --- /dev/null +++ b/src/ShipFit/RadialMenu.tsx @@ -0,0 +1,48 @@ +import React from "react"; + +import styles from "./ShipFit.module.css"; + +const highlightSettings = { + lowslot: { + width: 12, + height: 3, + x: 0, + y: 9, + }, + medslot: { + width: 3, + height: 12, + x: 9, + y: 0, + }, + hislot: { + width: 12, + height: 3, + x: 0, + y: 0, + }, +}; + +export const RadialMenu = (props: { type: "lowslot" | "medslot" | "hislot" }) => { + const highlight = highlightSettings[props.type]; + + return + + + + + + + + + + + + + + + + + + +} diff --git a/src/ShipFit/RingInner.tsx b/src/ShipFit/RingInner.tsx new file mode 100644 index 0000000..c49af5e --- /dev/null +++ b/src/ShipFit/RingInner.tsx @@ -0,0 +1,23 @@ +import React from "react"; + +import styles from "./ShipFit.module.css"; + +export const RingInner = () => { + return + + + + + + + + + + + + + + + + +} diff --git a/src/ShipFit/RingOuter.tsx b/src/ShipFit/RingOuter.tsx new file mode 100644 index 0000000..c17ffd2 --- /dev/null +++ b/src/ShipFit/RingOuter.tsx @@ -0,0 +1,17 @@ +import React from "react"; + +import styles from "./ShipFit.module.css"; + +export const RingOuter = () => { + return + + + + + + + + + + +} diff --git a/src/ShipFit/RingTop.tsx b/src/ShipFit/RingTop.tsx new file mode 100644 index 0000000..9703cda --- /dev/null +++ b/src/ShipFit/RingTop.tsx @@ -0,0 +1,19 @@ +import React from "react"; + +import styles from "./ShipFit.module.css"; + +export const RingTop = (props: { children: React.ReactNode }) => { + return
+ {props.children} +
+} + +export const RingTopItem = (props: { children: React.ReactNode, rotation: number }) => { + const rotationStyle = { + "--rotation": `${props.rotation}deg`, + } as React.CSSProperties; + + return
+ {props.children} +
+} diff --git a/src/ShipFit/ShipFit.module.css b/src/ShipFit/ShipFit.module.css index 31d2353..7143fea 100644 --- a/src/ShipFit/ShipFit.module.css +++ b/src/ShipFit/ShipFit.module.css @@ -1,121 +1,24 @@ - .fit { border-radius: 50%; border: 1px solid black; - height: calc(var(--radius) * 2); + height: 100%; position: relative; - width: calc(var(--radius) * 2); - - --radius-slots: calc(var(--radius) * 0.92); + width: 100%; } -.outerBand { - border-radius: 50%; - border: calc((var(--radius) - var(--radius-slots)) * 0.8) solid black; - box-sizing: border-box; - height: calc(var(--radius) * 2); +.ringOuter { + filter: drop-shadow(0 0 6px #000000); position: absolute; - width: calc(var(--radius) * 2); + width: 100%; z-index: 2; } - -.innerBand { - border-radius: 50%; - border: calc(var(--radius-slots) / 6 + var(--radius) - var(--radius-slots)) solid black; - box-sizing: border-box; - height: calc(var(--radius) * 2); - opacity: 0.5; +.ringInner { position: absolute; - width: calc(var(--radius) * 2); - z-index: 3; + width: 100%; + z-index: 1; } -.hull { - height: 1px; - left: var(--radius); - position: absolute; - top: var(--radius); - width: 1px; -} - -.hullInner { - margin-left: calc(var(--radius) * -1); - margin-top: calc(var(--radius) * -1); -} -.hullInner > img { - border-radius: 50%; - height: calc(var(--radius) * 2); - width: calc(var(--radius) * 2); -} - -.slots { - margin-left: calc(var(--radius) - var(--radius-slots)); - margin-top: calc(var(--radius) - var(--radius-slots)); - position: relative; -} - -.slot { - height: 1px; - left: var(--radius-slots); - position: absolute; - transform-origin: 0 var(--radius-slots); - transform: rotate(var(--rotation)); - width: 1px; - z-index: 4; -} - -.slotInner { - border-top-left-radius: 100% 4px; - border-top-right-radius: 100% 4px; - border: 1px solid black; - box-sizing: border-box; - height: calc(var(--radius-slots) / 7); - left: calc(var(--radius-slots) / 7 / 2 * -1); - position: absolute; - top: 2px; - transform: perspective(8px) rotateX(-1.5deg); - width: calc(var(--radius-slots) / 7); -} - -.slotInnerInvalid { - border: 1px solid red; -} - -.slotInner[data-state="Passive"] { - background-color: #0000002d; - border-color: #6c6c6c9f; - opacity: 0.3; -} -.slotInner[data-state="Online"] { - background-color: #9595952d; - border-color: #9595959f; -} -.slotInner[data-state="Active"] { - background-color: #87d3282d; - border-color: #87d3289f; -} -.slotInner[data-state="Overload"] { - background-color: #e8342b2d; - border-color: #e8342b9f; -} - -.slotItem { - --reverse-rotation: calc(-1 * var(--rotation)); - left: -32px; - position: absolute; - top: calc(-32px + var(--radius-slots) / 7 / 2); - transform: rotate(var(--reverse-rotation)) scale(calc(var(--scale) * 0.8)); -} - -.slotItemOffline { - opacity: 0.3; -} - -.slotItem > img { - border-top-left-radius: 32px; -} - -.fitlink { +.fitLink { height: 100%; left: 0; position: absolute; @@ -123,3 +26,99 @@ width: 100%; z-index: 3; } + +.hull { + height: 100%; + position: absolute; + width: 100%; +} + +.hull > img { + border-radius: 50%; + height: calc(100% - 2 * 3%); + left: 3%; + opacity: 0.8; + position: relative; + top: 3%; + width: calc(100% - 2 * 3%); +} + +.ringTop { + height: 100%; + position: absolute; + width: 100%; +} + +.ringTopItem { + height: 100%; + pointer-events: none; + position: absolute; + transform: rotate(var(--rotation)); + width: 100%; + z-index: 4; +} + +.ringTopItem > div, .ringTopItem > svg { + --reverse-rotation: calc(-1 * var(--rotation)); + left: 50%; + pointer-events: all; + position: absolute; + top: 3.5%; + transform: rotate(var(--reverse-rotation)); +} + +.radialMenu { + filter: drop-shadow(0 0 2px #ffffff); + position: absolute; + margin-top: 3.5%; + width: 2.5%; +} + +.slot { + height: 9.5%; + margin-left: -2.5%; + position: relative; + user-select: none; + width: 7%; +} + +.slot > svg { + height: 100%; + position: absolute; + transform: rotate(var(--rotation)); + width: 100%; + z-index: 4; +} + +.slotImage { + height: 100%; + position: absolute; + margin-top: 8%; + margin-left: -10%; + width: 100%; + z-index: 5; +} + +.slotImage > img { + border-top-left-radius: 50%; + width: 120%; +} + +.slot > svg { + fill: #999999; + stroke: #999999; +} +.slot[data-state="Active"] > svg { + fill: #8ae04a; + stroke: #8ae04a; +} +.slot[data-state="Overload"] > svg { + fill: #fd2d2d; + stroke: #fd2d2d; +} +.slot[data-state="Offline"] { + opacity: 0.3; +} +.slot[data-state="Unavailable"] { + opacity: 0.1; +} diff --git a/src/ShipFit/ShipFit.stories.tsx b/src/ShipFit/ShipFit.stories.tsx index 047a3d6..cce5a80 100644 --- a/src/ShipFit/ShipFit.stories.tsx +++ b/src/ShipFit/ShipFit.stories.tsx @@ -17,12 +17,14 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const withShipSnapshotProvider: Decorator<{ radius?: number }> = (Story, context) => { +const withShipSnapshotProvider: Decorator> = (Story, context) => { return ( - +
+ +
@@ -31,7 +33,7 @@ const withShipSnapshotProvider: Decorator<{ radius?: number }> = (Story, context export const Default: Story = { args: { - radius: 365, + width: 730, }, decorators: [withShipSnapshotProvider], parameters: { diff --git a/src/ShipFit/ShipFit.tsx b/src/ShipFit/ShipFit.tsx index 3c260ed..a2b43df 100644 --- a/src/ShipFit/ShipFit.tsx +++ b/src/ShipFit/ShipFit.tsx @@ -5,70 +5,69 @@ import { ShipSnapshotContext } from '../ShipSnapshotProvider'; import { FitLink } from './FitLink'; import { Hull } from './Hull'; import { Slot } from './Slot'; +import { RadialMenu } from "./RadialMenu"; +import { RingOuter } from "./RingOuter"; +import { RingInner } from "./RingInner"; +import { RingTop, RingTopItem } from "./RingTop"; import styles from "./ShipFit.module.css"; -export interface ShipFitProps { - radius?: number; -} - /** * Render a ship fit similar to how it is done in-game. */ -export const ShipFit = (props: ShipFitProps) => { - const radius = props.radius ?? 365; - +export const ShipFit = () => { const shipSnapshot = React.useContext(ShipSnapshotContext); const slots = shipSnapshot.slots; - const scaleStyle = { - "--radius": `${radius}px`, - "--scale": `${radius / 365}` - } as React.CSSProperties; - - return
-
-
+ return
+ + -
- = 1} rotation="-125deg" /> - = 2} rotation="-114deg" /> - = 3} rotation="-103deg" /> - = 4} rotation="-92deg" /> + + - = 1} rotation="-73deg" /> - = 2} rotation="-63deg" /> - = 3} rotation="-53deg" /> + = 1} main /> + = 2} /> + = 3} /> + = 4} /> + = 5} /> + = 6} /> + = 7} /> + = 8} /> - = 1} rotation="-34deg" /> - = 2} rotation="-24deg" /> - = 3} rotation="-14deg" /> - = 4} rotation="-4deg" /> - = 5} rotation="6deg" /> - = 6} rotation="16deg" /> - = 7} rotation="26deg" /> - = 8} rotation="36deg" /> + - = 1} rotation="55deg" /> - = 2} rotation="65deg" /> - = 3} rotation="75deg" /> - = 4} rotation="85deg" /> - = 5} rotation="95deg" /> - = 6} rotation="105deg" /> - = 7} rotation="115deg" /> - = 8} rotation="125deg" /> + = 1} /> + = 2} /> + = 3} /> + = 4} /> + = 5} /> + = 6} /> + = 7} /> + = 8} /> - = 1} rotation="144deg" /> - = 2} rotation="154deg" /> - = 3} rotation="164deg" /> - = 4} rotation="174deg" /> - = 5} rotation="184deg" /> - = 6} rotation="194deg" /> - = 7} rotation="204deg" /> - = 8} rotation="214deg" /> -
+ + + = 1} /> + = 2} /> + = 3} /> + = 4} /> + = 5} /> + = 6} /> + = 7} /> + = 8} /> + + = 1} /> + = 2} /> + = 3} /> + + = 1} /> + = 2} /> + = 3} /> + = 4} /> +
}; diff --git a/src/ShipFit/Slot.tsx b/src/ShipFit/Slot.tsx index 2b65ea0..e904de5 100644 --- a/src/ShipFit/Slot.tsx +++ b/src/ShipFit/Slot.tsx @@ -1,5 +1,4 @@ import React from "react"; -import ctlx from "clsx"; import { EveDataContext } from '../EveDataProvider'; import { ShipSnapshotContext } from '../ShipSnapshotProvider'; @@ -31,26 +30,56 @@ const stateRotation: Record = { "Overload": ["Passive", "Online", "Active", "Overload"], }; -export const Slot = (props: {type: string, index: number, fittable: boolean, rotation: string}) => { +export const Slot = (props: { type: string, index: number, fittable: boolean, main?: boolean }) => { const eveData = React.useContext(EveDataContext); const shipSnapshot = React.useContext(ShipSnapshotContext); - const rotationStyle = { "--rotation": props.rotation } as React.CSSProperties; const esiFlag = esiFlagMapping[props.type][props.index - 1]; const esiItem = shipSnapshot?.items?.find((item) => item.flag == esiFlag); + const active = esiItem?.max_state !== "Passive" && esiItem?.max_state !== "Online"; + let item = <>; + let svg = <>; + + if (props.main !== undefined) { + svg = + + + + + + + + + + + + + ; + } + + svg = <> + {svg} + + + {props.fittable && active && } + {props.fittable && !active && } + + ; /* Not fittable and nothing fitted; no need to render the slot. */ if (esiItem === undefined && !props.fittable) { - return <> + return
+ {svg} +
} if (esiItem !== undefined) { item = } - const isOffline = esiItem?.state === "Passive" && esiItem?.max_state !== "Passive"; + const state = (esiItem?.state === "Passive" && esiItem?.max_state !== "Passive") ? "Offline" : esiItem?.state; function cycleState(e: React.MouseEvent) { if (!shipSnapshot?.loaded || !esiItem) return; @@ -68,10 +97,9 @@ export const Slot = (props: {type: string, index: number, fittable: boolean, rot shipSnapshot.setItemState(esiItem.flag, newState); } - return
-
-
-
+ return
+ {svg} +
{item}
diff --git a/src/ShipFitExtended/ShipFitExtended.module.css b/src/ShipFitExtended/ShipFitExtended.module.css index 4dc5768..125fa1b 100644 --- a/src/ShipFitExtended/ShipFitExtended.module.css +++ b/src/ShipFitExtended/ShipFitExtended.module.css @@ -2,10 +2,9 @@ background-color: #111111; color: #c5c5c5; font-size: 15px; - padding-bottom: 60px; - padding-left: 50px; + height: 100%; position: relative; - width: calc(var(--radius) * 2 + 2 * 50px); + width: 100%; } .cpuPg { diff --git a/src/ShipFitExtended/ShipFitExtended.stories.tsx b/src/ShipFitExtended/ShipFitExtended.stories.tsx index 208689a..59b3527 100644 --- a/src/ShipFitExtended/ShipFitExtended.stories.tsx +++ b/src/ShipFitExtended/ShipFitExtended.stories.tsx @@ -17,12 +17,14 @@ const meta: Meta = { export default meta; type Story = StoryObj; -const withShipSnapshotProvider: Decorator<{ radius?: number }> = (Story, context) => { +const withShipSnapshotProvider: Decorator> = (Story, context) => { return ( - +
+ +
@@ -31,7 +33,7 @@ const withShipSnapshotProvider: Decorator<{ radius?: number }> = (Story, context export const Default: Story = { args: { - radius: 365, + width: 730, }, decorators: [withShipSnapshotProvider], parameters: { diff --git a/src/ShipFitExtended/ShipFitExtended.tsx b/src/ShipFitExtended/ShipFitExtended.tsx index f09c2ab..fe69688 100644 --- a/src/ShipFitExtended/ShipFitExtended.tsx +++ b/src/ShipFitExtended/ShipFitExtended.tsx @@ -5,10 +5,6 @@ import { ShipAttribute } from "../ShipAttribute"; import styles from "./ShipFitExtended.module.css"; -export interface ShipFitExtendedProps { - radius?: number; -} - const CargoHold = () => { return
@@ -61,15 +57,9 @@ const CpuPg = (props: { title: string, children: React.ReactNode }) => { * also adds the cargo hold, drone bay, and CPU/PG usage at the * bottom of the fit. */ -export const ShipFitExtended = (props: ShipFitExtendedProps) => { - const radius = props.radius ?? 365; - - const scaleStyle = { - "--radius": `${radius}px`, - } as React.CSSProperties; - - return
- +export const ShipFitExtended = () => { + return
+
diff --git a/src/ShipSnapshotProvider/ShipSnapshotProvider.tsx b/src/ShipSnapshotProvider/ShipSnapshotProvider.tsx index bdefb1f..137efec 100644 --- a/src/ShipSnapshotProvider/ShipSnapshotProvider.tsx +++ b/src/ShipSnapshotProvider/ShipSnapshotProvider.tsx @@ -21,8 +21,8 @@ export interface ShipSnapshotItem { type_id: number, quantity: number, flag: number, - state: string, - max_state: string, + state: "Passive" | "Online" | "Active" | "Overload", + max_state: "Passive" | "Online" | "Active" | "Overload", attributes: Map, effects: number[], }