diff --git a/.storybook/fits.ts b/.storybook/fits.ts index 2c6bb21..cb3405d 100644 --- a/.storybook/fits.ts +++ b/.storybook/fits.ts @@ -31,6 +31,8 @@ Loki Propulsion - Wake Limiter Hammerhead II x1 `; +export const hashFit = "fit:v1:H4sIAAAAAAAAClXOMQ7CMAwF0L2n6AE8xD92bLMiRhi4AQMSrND7i7itGrG9JHb+R4QLnet8fyzL8zOf5tv7+7pcaWIoiTY04u7WrcGrLe/LZk+zppkA7CODdTBIGOydKFTVoiRzzfMdIFZrmYdKHOyWlEEdW0ZQW3N7hYNxsJZBHsRgL+ammRagypZBU9RO3yid1bKuEkRyylDy0CxIxTBthXQdScmmIt7I/+72D6JNPx7qugdyAQAA"; + export const fullFit = { "name": "C3 Ratter : NishEM", "ship_type_id": 29984, diff --git a/src/EveShipFitHash/EveShipFitHash.stories.tsx b/src/EveShipFitHash/EveShipFitHash.stories.tsx new file mode 100644 index 0000000..c1199fe --- /dev/null +++ b/src/EveShipFitHash/EveShipFitHash.stories.tsx @@ -0,0 +1,32 @@ +import type { Decorator, Meta, StoryObj } from '@storybook/react'; +import React from "react"; + +import { hashFit } from '../../.storybook/fits'; + +import { EveDataProvider } from '../EveDataProvider'; +import { EveShipFitHash } from './EveShipFitHash'; + +const meta: Meta = { + component: EveShipFitHash, + tags: ['autodocs'], + title: 'Function/EveShipFitHash', +}; + +const withEveDataProvider: Decorator<{fitHash: string}> = (Story) => { + return ( + + + + ); +} + + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + "fitHash": hashFit, + }, + decorators: [withEveDataProvider], +}; diff --git a/src/EveShipFitHash/EveShipFitHash.tsx b/src/EveShipFitHash/EveShipFitHash.tsx new file mode 100644 index 0000000..2108787 --- /dev/null +++ b/src/EveShipFitHash/EveShipFitHash.tsx @@ -0,0 +1,84 @@ +import React from "react"; + +import { EsiFit } from "../ShipSnapshotProvider"; + +async function decompress(base64compressedBytes: string): Promise { + const stream = new Blob([Uint8Array.from(atob(base64compressedBytes), c => c.charCodeAt(0))]).stream(); + const decompressedStream = stream.pipeThrough(new DecompressionStream("gzip")); + const reader = decompressedStream.getReader(); + + let result = ""; + while (true) { + const {done, value} = await reader.read(); + if (done) break; + + result += String.fromCharCode.apply(null, value); + } + + return result; +} + +async function decodeEsiFit(fitVersion: string, fitCompressed: string): Promise { + if (fitVersion != "v1") return undefined; + + const fitEncoded = await decompress(fitCompressed); + + const fitLines = fitEncoded.trim().split("\n"); + const fitHeader = fitLines[0].split(","); + + const fitItems = fitLines.slice(1).map((line) => { + const item = line.split(","); + return { + flag: parseInt(item[0]), + type_id: parseInt(item[1]), + quantity: parseInt(item[2]), + }; + }); + + return { + ship_type_id: parseInt(fitHeader[0]), + name: fitHeader[1], + description: fitHeader[2], + items: fitItems, + }; +} + +/** + * Convert a hash from window.location.hash to an ESI fit. + */ +export async function eveShipFitHash(fitHash: string): Promise { + if (!fitHash) return undefined; + + const fitPrefix = fitHash.split(":")[0]; + const fitVersion = fitHash.split(":")[1]; + const fitEncoded = fitHash.split(":")[2]; + + if (fitPrefix !== "fit") return undefined; + + const esiFit = await decodeEsiFit(fitVersion, fitEncoded); + return esiFit; +} + +export interface EveShipFitHashProps { + /** The hash of the fit string. */ + fitHash: string; +} + +/** + * eveShipFitHash(fitHash) converts a hash from window.location.hash to an ESI fit. + * + * Note: do not use this React component itself, but the eveShipFitHash() function instead. + */ +export const EveShipFitHash = (props: EveShipFitHashProps) => { + const [esiFit, setEsiFit] = React.useState(undefined); + + React.useEffect(() => { + async function getFit(fitHash: string) { + setEsiFit(await eveShipFitHash(fitHash)); + } + + getFit(props.fitHash); + }, [props.fitHash]); + + return
{JSON.stringify(esiFit, null, 2)}
+}; diff --git a/src/EveShipFitHash/index.ts b/src/EveShipFitHash/index.ts new file mode 100644 index 0000000..c3a731f --- /dev/null +++ b/src/EveShipFitHash/index.ts @@ -0,0 +1 @@ +export { eveShipFitHash } from "./EveShipFitHash"; diff --git a/src/EveShipFitLink/EveShipFitLink.stories.tsx b/src/EveShipFitLink/EveShipFitLink.stories.tsx new file mode 100644 index 0000000..c17de1e --- /dev/null +++ b/src/EveShipFitLink/EveShipFitLink.stories.tsx @@ -0,0 +1,43 @@ +import type { Decorator, Meta, StoryObj } from '@storybook/react'; +import React from "react"; + +import { fullFit } from '../../.storybook/fits'; + +import { EveDataProvider } from '../EveDataProvider'; +import { DogmaEngineProvider } from '../DogmaEngineProvider'; +import { ShipSnapshotProvider } from '../ShipSnapshotProvider'; +import { EveShipFitLink } from './EveShipFitLink'; + +const meta: Meta = { + component: EveShipFitLink, + tags: ['autodocs'], + title: 'Function/EveShipFitLink', +}; + +const withShipSnapshotProvider: Decorator<{ radius?: number }> = (Story, context) => { + return ( + + + + + + + + ); +} + + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + }, + decorators: [withShipSnapshotProvider], + parameters: { + snapshot: { + fit: fullFit, + skills: {}, + } + }, +}; diff --git a/src/EveShipFitLink/EveShipFitLink.tsx b/src/EveShipFitLink/EveShipFitLink.tsx new file mode 100644 index 0000000..18f678c --- /dev/null +++ b/src/EveShipFitLink/EveShipFitLink.tsx @@ -0,0 +1,65 @@ +import React from "react"; + +import { EsiFit, ShipSnapshotContext } from "../ShipSnapshotProvider"; + +async function compress(str: string): Promise { + const stream = new Blob([str]).stream(); + const compressedStream = stream.pipeThrough(new CompressionStream("gzip")); + const reader = compressedStream.getReader(); + + let result = ""; + while (true) { + const {done, value} = await reader.read(); + if (done) break; + + result += String.fromCharCode.apply(null, value); + } + + return btoa(result); +} + +async function encodeEsiFit(esiFit: EsiFit): Promise { + let result = `${esiFit.ship_type_id},${esiFit.name},${esiFit.description}\n`; + + for (const item of esiFit.items) { + result += `${item.flag},${item.type_id},${item.quantity}\n`; + } + + return "v1:" + await compress(result); +} + +/** + * Returns a link to https://eveship.fit that contains the current fit. + */ +export function useEveShipFitLink() { + const [fitLink, setFitLink] = React.useState(""); + const shipSnapshot = React.useContext(ShipSnapshotContext); + + React.useEffect(() => { + if (!shipSnapshot?.loaded) return; + + async function doCreateLink() { + if (!shipSnapshot?.fit) { + setFitLink(""); + return; + } + + const fitHash = await encodeEsiFit(shipSnapshot.fit); + setFitLink(`https://eveship.fit/#fit:${fitHash}`); + } + doCreateLink(); + }, [shipSnapshot?.loaded, shipSnapshot?.fit]); + + return fitLink; +}; + +/** + * useEveShipFitLink() converts the current fit into a link to https://eveship.fit. + * + * Note: do not use this React component itself, but the useEveShipFitLink() React hook instead. + */ +export const EveShipFitLink = () => { + const eveShipFitLink = useEveShipFitLink(); + + return
{eveShipFitLink}
+}; diff --git a/src/EveShipFitLink/index.ts b/src/EveShipFitLink/index.ts new file mode 100644 index 0000000..4119415 --- /dev/null +++ b/src/EveShipFitLink/index.ts @@ -0,0 +1 @@ +export { useEveShipFitLink } from "./EveShipFitLink"; diff --git a/src/FormatEftToEsi/FormatEftToEsi.tsx b/src/FormatEftToEsi/FormatEftToEsi.tsx index 8b9913b..66aa4b2 100644 --- a/src/FormatEftToEsi/FormatEftToEsi.tsx +++ b/src/FormatEftToEsi/FormatEftToEsi.tsx @@ -124,7 +124,9 @@ export interface FormatEftToEsiProps { } /** - * Use useFormatEftToEsi() instead of this component. + * useFormatEftToEsi() converts an EFT string to an ESI JSON object. + * + * Note: do not use this React component itself, but the useFormatEftToEsi() React hook instead. */ export const FormatEftToEsi = (props: FormatEftToEsiProps) => { const esiFit = useFormatEftToEsi(); diff --git a/src/ShipFit/FitLink.tsx b/src/ShipFit/FitLink.tsx new file mode 100644 index 0000000..08fa9f7 --- /dev/null +++ b/src/ShipFit/FitLink.tsx @@ -0,0 +1,28 @@ +import React from "react"; + +import { useEveShipFitLink } from "../EveShipFitLink"; + +import styles from "./ShipFit.module.css"; + +export const FitLink = () => { + const link = useEveShipFitLink(); + + /* Detect if the fit is loaded on https://eveship.fit */ + const isEveShipFit = window.location.hostname === "eveship.fit"; + const linkText = isEveShipFit ? "link to fit" : "open on eveship.fit"; + + return +} diff --git a/src/ShipFit/ShipFit.module.css b/src/ShipFit/ShipFit.module.css index 595db10..3bdef58 100644 --- a/src/ShipFit/ShipFit.module.css +++ b/src/ShipFit/ShipFit.module.css @@ -114,3 +114,12 @@ .slotItem > img { border-top-left-radius: 32px; } + +.fitlink { + height: 100%; + left: 0; + position: absolute; + top: 0; + width: 100%; + z-index: 100; +} diff --git a/src/ShipFit/ShipFit.tsx b/src/ShipFit/ShipFit.tsx index 29c2b46..a606f0c 100644 --- a/src/ShipFit/ShipFit.tsx +++ b/src/ShipFit/ShipFit.tsx @@ -3,6 +3,7 @@ import React from "react"; import { EveDataContext } from '../EveDataProvider'; import { ShipSnapshotContext } from '../ShipSnapshotProvider'; +import { FitLink } from './FitLink'; import { Hull } from './Hull'; import { Slot } from './Slot'; @@ -54,6 +55,7 @@ export const ShipFit = (props: ShipFitProps) => {
+
= 1} rotation="-125deg" /> diff --git a/src/index.ts b/src/index.ts index b8fd4be..da3fe71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,7 @@ export * from './DogmaEngineProvider'; export * from './EveDataProvider'; +export * from './EveShipFitHash'; +export * from './EveShipFitLink'; export * from './FormatEftToEsi'; export * from './Icon'; export * from './ShipAttribute';