import { get as lodashGet } from 'lodash' import React, { useMemo, useRef, useState, useCallback, useEffect } from 'react' import { connect } from 'react-redux' import styled from 'styled-components' import { useReactTable, getCoreRowModel, getSortedRowModel, getFilteredRowModel, ColumnDef, flexRender, SortingState, FilterFn, } from '@tanstack/react-table' import { useVirtualizer } from '@tanstack/react-virtual' import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' import { WindowEnv } from 'views/components/etc/window-env' import { allShipRowsSelector, shipInfoConfigSelector, shipInfoFiltersSelector, shipTypesSelecor, expeditionShipsSelector, columnOrderSelector, columnVisibilitySelector, columnPinningSelector, } from '../selectors' import { IShipRawData } from './cells' import { columns as dataColumns, TableRow } from './columns-config' import { TitleCell } from './title-cell' import { isShipCompleted, canEquipDaihatsu } from '../utils' const ROW_INDEX_WIDTH = 40 const ROW_HEIGHT = 35 // Jotai atoms for active cell tracking const activeRowAtom = atom(-1) const activeColumnAtom = atom(-1) // Jotai atoms for filter state export const filterShipTypesAtom = atom([]) export const filterExpeditionShipsAtom = atom([]) export const filterConfigAtom = atom({}) export const filterSettingsAtom = atom({ minLevel: 0, maxLevel: 450 }) // Derived atom that combines all filter criteria export const globalFilterAtom = atom((get) => ({ shipTypes: get(filterShipTypesAtom), expeditionShips: get(filterExpeditionShipsAtom), config: get(filterConfigAtom), filters: get(filterSettingsAtom), })) // Atom to store filtered ship IDs from the table (for export to use) export const filteredShipIdsAtom = atom([]) // Cache filter results per row to avoid re-computing for each column const filterCache = new Map() // Global filter function that applies all filter criteria // Note: This is called once per column per row, so we cache the result const globalFilterFn: FilterFn = (row, columnId, filterValue) => { const { shipData } = row.original const shipId = shipData.ship.api_id // Check cache first - if we've already filtered this row, return cached result if (filterCache.has(shipId)) { return filterCache.get(shipId)! } const { shipTypes, expeditionShips, config, filters } = filterValue as { shipTypes: number[] expeditionShips: number[] config: any filters: any } const { ship, $ship, fleetIdMap, db } = shipData const typeId = $ship.api_stype const lv = ship.api_lv const locked = ship.api_locked const fleetId = fleetIdMap[shipId] const after = parseInt($ship.api_aftershipid || '0', 10) const sallyArea = ship.api_sally_area || 0 const exslot = ship.api_slot_ex const cond = ship.api_cond const isCompleted = isShipCompleted(ship, $ship) const daihatsu = canEquipDaihatsu($ship.api_id, db) // Type filter if (!shipTypes.includes(typeId)) { filterCache.set(shipId, false) return false } // Level range filter const { minLevel, maxLevel } = filters if (lv < Math.min(minLevel, maxLevel) || lv > Math.max(minLevel, maxLevel)) { filterCache.set(shipId, false) return false } // All yes/no filters - using shared filter logic from selectors const filtersToCheck = [ { key: 'locked', value: locked === 1 }, { key: 'expedition', value: expeditionShips.includes(shipId) }, { key: 'modernization', value: isCompleted }, { key: 'remodel', value: after !== 0 }, { key: 'inFleet', value: fleetId > -1 }, { key: 'sparkle', value: cond >= 50 }, { key: 'exSlot', value: exslot !== 0 }, { key: 'daihatsu', value: daihatsu }, ] const filterFailed = filtersToCheck.some(({ key, value }) => { const filterArray = config[key] || [true, true] const [yesChecked, noChecked] = filterArray // If both are checked, no filtering needed if (yesChecked && noChecked) return false // If both unchecked, filter out if (!yesChecked && !noChecked) return true // Check if value matches the filter if (yesChecked && !value) return true if (noChecked && value) return true return false }) if (filterFailed) { filterCache.set(shipId, false) return false } // Sally area filter const { sallyAreaChecked } = config const sallyAreaArray = sallyAreaChecked || [] const allChecked = sallyAreaArray.reduce( (all: boolean, checked: boolean) => all && checked, true, ) if ( !allChecked && !(typeof sallyArea !== 'undefined' ? sallyAreaArray[sallyArea || 0] : true) ) { filterCache.set(shipId, false) return false } // Cache the result filterCache.set(shipId, true) return true } const TableWrapper = styled.div` flex: 1; margin: 0; padding: 0; overflow: auto; position: relative; /* Show scrollbars */ &::-webkit-scrollbar { width: 12px; height: 12px; } &::-webkit-scrollbar-track { background: ${(props) => props.theme.DARK_GRAY5}; } &::-webkit-scrollbar-thumb { background: ${(props) => props.theme.GRAY1}; border-radius: 6px; &:hover { background: ${(props) => props.theme.GRAY3}; } } /* Firefox scrollbar styling */ scrollbar-width: auto; scrollbar-color: ${(props) => props.theme.GRAY1} ${(props) => props.theme.DARK_GRAY5}; ` const Spacer = styled.div` height: 35px; ` const TableContainer = styled.div` display: inline-block; min-width: 100%; ` const TableHeader = styled.div` display: flex; position: sticky; top: 0; z-index: 1; background: ${(props) => props.theme.DARK_GRAY3}; ` const TableBody = styled.div` position: relative; ` const HeaderCell = styled.div<{ width: number $isPinned?: 'left' | 'right' | false $leftOffset?: number }>` width: ${(props) => props.width}px; flex-shrink: 0; height: 100%; display: flex; align-items: stretch; ${(props) => props.$isPinned && ` position: sticky; ${props.$isPinned === 'left' ? `left: ${props.$leftOffset || 0}px;` : ''} ${props.$isPinned === 'right' ? `right: ${props.$leftOffset || 0}px;` : ''} background: ${props.theme.DARK_GRAY3}; z-index: 2; &::after { content: ''; position: absolute; top: 0; bottom: 0; width: 1px; background: ${props.theme.GRAY1}; ${props.$isPinned === 'left' ? 'right: 0;' : 'left: 0;'} } `} ` const Row = styled.div<{ $isEven?: boolean }>` display: flex; position: absolute; width: 100%; background-color: ${(props) => props.$isEven ? 'rgba(0, 123, 255, 0.05)' : 'transparent'}; ` interface ShipInfoTableAreaBaseProps { allShipsData: IShipRawData[] window: Window sortName: string sortOrder: number shipTypes: number[] expeditionShips: number[] filterConfig: any filterSettings: any columnOrder: string[] columnVisibility: Record columnPinning: { left?: string[] right?: string[] } } // Styled wrapper for row index cells const RowIndexCellWrapper = styled.div<{ $isHighlighted: boolean }>` width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background-color: ${(props) => props.$isHighlighted ? 'rgba(138, 155, 168, 0.3)' : 'transparent'}; cursor: default; ` // Cell wrapper components that subscribe to Jotai atoms const RowIndexCell: React.FC<{ rowIndex: number onClickCell: (columnIndex: number, rowIndex: number) => void onContextMenu: () => void }> = ({ rowIndex, onClickCell, onContextMenu }) => { const activeRow = useAtomValue(activeRowAtom) const isHighlighted = activeRow === rowIndex return ( onClickCell(0, rowIndex)} onContextMenu={onContextMenu} > {rowIndex + 1} ) } // Styled wrapper for cells with highlighting const CellWrapperDiv = styled.div<{ $isHighlighted: boolean }>` width: 100%; height: 100%; display: flex; align-items: center; background-color: ${(props) => props.$isHighlighted ? 'rgba(138, 155, 168, 0.3)' : 'transparent'}; ` // Wrapper component for cells with highlighting const CellWrapper: React.FC<{ columnIndex: number rowIndex: number onClickCell: (columnIndex: number, rowIndex: number) => void onContextMenu: () => void children: React.ReactNode }> = ({ columnIndex, rowIndex, onClickCell, onContextMenu, children }) => { const activeRow = useAtomValue(activeRowAtom) const activeColumn = useAtomValue(activeColumnAtom) const isHighlighted = activeColumn === columnIndex || activeRow === rowIndex return ( onClickCell(columnIndex, rowIndex)} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { onClickCell(columnIndex, rowIndex) } }} onContextMenu={onContextMenu} > {children} ) } const ShipInfoTableAreaBase: React.FC = ({ allShipsData, window: windowObj, sortName, sortOrder, shipTypes, expeditionShips, filterConfig, filterSettings, columnOrder, columnVisibility, columnPinning, }) => { const [activeRow, setActiveRow] = useAtom(activeRowAtom) const [activeColumn, setActiveColumn] = useAtom(activeColumnAtom) const tableContainerRef = useRef(null) const hasSpacer = process.platform === 'darwin' && !windowObj.isMain // Sync Redux filter data to Jotai atoms const setFilterShipTypes = useSetAtom(filterShipTypesAtom) const setFilterExpeditionShips = useSetAtom(filterExpeditionShipsAtom) const setFilterConfig = useSetAtom(filterConfigAtom) const setFilterSettings = useSetAtom(filterSettingsAtom) useEffect(() => { filterCache.clear() setFilterShipTypes(shipTypes) setFilterExpeditionShips(expeditionShips) setFilterConfig(filterConfig) setFilterSettings(filterSettings) }, [ shipTypes, expeditionShips, filterConfig, filterSettings, setFilterShipTypes, setFilterExpeditionShips, setFilterConfig, setFilterSettings, ]) const saveSortRules = useCallback((name: string, order: number) => { window.config.set('plugin.ShipInfo.sortName', name) window.config.set('plugin.ShipInfo.sortOrder', order) }, []) const handleClickTitle = useCallback( (columnName: string) => { if (sortName !== columnName) { const order = columnName === 'id' || columnName === 'type' || columnName === 'name' ? 1 : 0 saveSortRules(columnName, order) } else { saveSortRules(sortName, (sortOrder + 1) % 2) } }, [sortName, sortOrder, saveSortRules], ) const onContextMenu = useCallback(() => { setActiveColumn(-1) setActiveRow(-1) }, [setActiveColumn, setActiveRow]) const onClickCell = useCallback( (columnIndex: number, rowIndex: number) => { const off = activeColumn === columnIndex && activeRow === rowIndex setActiveColumn(off ? -1 : columnIndex) setActiveRow(off ? -1 : rowIndex) }, [activeColumn, activeRow, setActiveColumn, setActiveRow], ) // Build table data from all ships const data = useMemo( () => allShipsData.map((shipData, index) => ({ id: shipData.ship.api_id, shipData, index, })), [allShipsData], ) // Use Jotai atom for global filter (read-only, derived from other atoms) const globalFilterValue = useAtomValue(globalFilterAtom) // Define columns with row index column and highlighting wrappers const columns = useMemo[]>(() => { // Row index column const rowIndexColumn: ColumnDef = { id: 'rowIndex', header: () =>
, accessorFn: (row) => row.index, cell: ({ row }) => ( ), size: ROW_INDEX_WIDTH, enableSorting: false, } // Wrap data columns with title headers and highlighting const wrappedColumns = dataColumns.map((col, index) => { const columnId = col.id as string const meta = col.meta as any const originalCell = col.cell return { ...col, header: () => ( meta?.sortable !== false && handleClickTitle(columnId) } /> ), cell: (props: any) => ( {typeof originalCell === 'function' ? originalCell(props) : null} ), size: meta?.width || 100, } }) return [rowIndexColumn, ...wrappedColumns] as ColumnDef[] }, [sortName, sortOrder, handleClickTitle, onClickCell, onContextMenu]) // Initialize table with sorting state from Redux const [sorting, setSorting] = useState([ { id: sortName, desc: sortOrder === 0 }, ]) // Update sorting state when Redux state changes useEffect(() => { setSorting([{ id: sortName, desc: sortOrder === 0 }]) }, [sortName, sortOrder]) const table = useReactTable({ data, columns, state: { sorting, globalFilter: globalFilterValue, columnOrder, columnVisibility, columnPinning, }, onSortingChange: setSorting, onGlobalFilterChange: () => { // Global filter is managed by Jotai atoms }, getCoreRowModel: getCoreRowModel(), getFilteredRowModel: getFilteredRowModel(), getSortedRowModel: getSortedRowModel(), globalFilterFn, enableFilters: true, enableGlobalFilter: true, enableColumnPinning: true, }) // Get filtered rows from table const { rows } = table.getRowModel() // Store filtered ship IDs in atom for export to use const setFilteredShipIds = useSetAtom(filteredShipIdsAtom) useEffect(() => { const filteredIds = rows.map((row) => row.original.shipData.ship.api_id) setFilteredShipIds(filteredIds) }, [rows, setFilteredShipIds]) // Virtualizer for rows const rowVirtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => tableContainerRef.current, estimateSize: () => ROW_HEIGHT, overscan: 10, }) const virtualRows = rowVirtualizer.getVirtualItems() const totalSize = rowVirtualizer.getTotalSize() return ( {hasSpacer && } {table.getHeaderGroups().map((headerGroup) => ( {headerGroup.headers.map((header) => { const isPinned = header.column.getIsPinned() const leftOffset = isPinned ? header.column.getStart(isPinned) : undefined return ( {flexRender( header.column.columnDef.header, header.getContext(), )} ) })} ))} {virtualRows.map((virtualRow) => { const row = rows[virtualRow.index] return ( {row.getVisibleCells().map((cell) => { const isPinned = cell.column.getIsPinned() const leftOffset = isPinned ? cell.column.getStart(isPinned) : undefined return ( {flexRender( cell.column.columnDef.cell, cell.getContext(), )} ) })} ) })} ) } interface ReduxState { config: Record [key: string]: any } interface MappedProps { allShipsData: IShipRawData[] shipTypes: number[] expeditionShips: number[] filterConfig: any filterSettings: any columnOrder: string[] columnVisibility: Record columnPinning: { left?: string[] right?: string[] } sortName: string sortOrder: number } const sortNameSelector = (state: ReduxState): string => lodashGet(state.config, 'plugin.ShipInfo.sortName', 'lv') const sortOrderSelector = (state: ReduxState): number => lodashGet(state.config, 'plugin.ShipInfo.sortOrder', 0) const mapStateToProps = (state: ReduxState): MappedProps => ({ allShipsData: allShipRowsSelector(state as any) as IShipRawData[], shipTypes: shipTypesSelecor(state as any) as number[], expeditionShips: expeditionShipsSelector(state as any) as number[], filterConfig: shipInfoConfigSelector(state as any), filterSettings: shipInfoFiltersSelector(state as any), columnOrder: columnOrderSelector(state as any) as string[], columnVisibility: columnVisibilitySelector(state as any) as Record< string, boolean >, columnPinning: columnPinningSelector(state as any) as { left?: string[] right?: string[] }, sortName: sortNameSelector(state), sortOrder: sortOrderSelector(state), }) const ShipInfoTableArea = connect(mapStateToProps)(ShipInfoTableAreaBase) export const TableView = (props: object) => ( {({ window }) => } )