diff --git a/src/app/components/AvailableModulesMenu.jsx b/src/app/components/AvailableModulesMenu.jsx
index 26617159..d6ec9e9d 100644
--- a/src/app/components/AvailableModulesMenu.jsx
+++ b/src/app/components/AvailableModulesMenu.jsx
@@ -1,120 +1,19 @@
import React from 'react';
import PropTypes from 'prop-types';
-import * as ModuleUtils from '../shipyard/ModuleUtils';
import TranslatedComponent from './TranslatedComponent';
import { stopCtxPropagation } from '../utils/UtilityFunctions';
import cn from 'classnames';
import { MountFixed, MountGimballed, MountTurret } from './SvgIcons';
import FuzzySearch from 'react-fuzzy';
+import { getModuleInfo } from 'ed-forge/lib/data/items';
+import { groupBy, sortBy } from 'lodash';
const PRESS_THRESHOLD = 500; // mouse/touch down threshold
-/*
- * Categorisation of module groups
- */
-const GRPCAT = {
- 'sg': 'shields',
- 'bsg': 'shields',
- 'psg': 'shields',
- 'scb': 'shields',
- 'cc': 'limpet controllers',
- 'fx': 'limpet controllers',
- 'hb': 'limpet controllers',
- 'pc': 'limpet controllers',
- 'rpl': 'limpet controllers',
- 'pce': 'passenger cabins',
- 'pci': 'passenger cabins',
- 'pcm': 'passenger cabins',
- 'pcq': 'passenger cabins',
- 'fh': 'hangars',
- 'pv': 'hangars',
- 'fs': 'fuel',
- 'ft': 'fuel',
- 'hr': 'structural reinforcement',
- 'mrp': 'structural reinforcement',
- 'bl': 'lasers',
- 'pl': 'lasers',
- 'ul': 'lasers',
- 'ml': 'lasers',
- 'c': 'projectiles',
- 'mc': 'projectiles',
- 'axmc': 'experimental',
- 'fc': 'projectiles',
- 'rfl': 'experimental',
- 'pa': 'projectiles',
- 'rg': 'projectiles',
- 'mr': 'ordnance',
- 'axmr': 'experimental',
- 'rcpl': 'experimental',
- 'dtl': 'experimental',
- 'tbsc': 'experimental',
- 'tbem': 'experimental',
- 'tbrfl': 'experimental',
- 'mahr': 'experimental',
- 'rsl': 'experimental',
- 'tp': 'ordnance',
- 'nl': 'ordnance',
- 'sc': 'scanners',
- 'ss': 'scanners',
- // Utilities
- 'cs': 'scanners',
- 'kw': 'scanners',
- 'ws': 'scanners',
- 'xs': 'scanners',
- 'ch': 'defence',
- 'po': 'defence',
- 'ec': 'defence',
- 'sfn': 'defence',
- // Guardian
- 'gpp': 'guardian',
- 'gpc': 'guardian',
- 'gsrp': 'guardian',
- 'ggc': 'guardian',
- 'gfsb': 'guardian',
- 'gmrp': 'guardian',
- 'gsc': 'guardian',
- 'ghrp': 'guardian',
-
- // Mining
- 'scl': 'mining',
- 'pwa': 'mining',
- 'sdm': 'mining',
-
- // Assists
- 'dc': 'flight assists',
- 'sua': 'flight assists',
-};
-// Order here is the order in which items will be shown in the modules menu
-const CATEGORIES = {
- // Internals
- 'am': ['am'],
- 'cr': ['cr'],
- 'fi': ['fi'],
- 'fuel': ['ft', 'fs'],
- 'hangars': ['fh', 'pv'],
- 'limpet controllers': ['cc', 'fx', 'hb', 'pc', 'rpl'],
- 'passenger cabins': ['pce', 'pci', 'pcm', 'pcq'],
- 'rf': ['rf'],
- 'shields': ['sg', 'bsg', 'psg', 'scb'],
- 'structural reinforcement': ['hr', 'mrp'],
- 'flight assists': ['dc', 'sua'],
-
- // Hardpoints
- 'lasers': ['pl', 'ul', 'bl'],
- 'projectiles': ['mc', 'c', 'fc', 'pa', 'rg'],
- 'ordnance': ['mr', 'tp', 'nl'],
- // Utilities
- 'sb': ['sb'],
- 'hs': ['hs'],
- 'defence': ['ch', 'po', 'ec'],
- 'scanners': ['sc', 'ss', 'cs', 'kw', 'ws'], // Overloaded with internal scanners
- // Experimental
- 'experimental': ['axmc', 'axmr', 'rfl', 'tbrfl', 'tbsc', 'tbem', 'xs', 'sfn', 'rcpl', 'dtl', 'rsl', 'mahr',],
-
- // Guardian
- 'guardian': ['gpp', 'gpd', 'gpc', 'ggc', 'gsrp', 'gfsb', 'ghrp', 'gmrp', 'gsc'],
-
- 'mining': ['ml', 'scl', 'pwa', 'sdm', 'abl'],
+const MOUNT_MAP = {
+ fixed: ,
+ gimbal: ,
+ turret: ,
};
/**
@@ -127,9 +26,6 @@ export default class AvailableModulesMenu extends TranslatedComponent {
m: PropTypes.object,
ship: PropTypes.object.isRequired,
warning: PropTypes.func,
- firstSlotId: PropTypes.string,
- lastSlotId: PropTypes.string,
- activeSlotId: PropTypes.string,
slotDiv: PropTypes.object
};
@@ -143,7 +39,6 @@ export default class AvailableModulesMenu extends TranslatedComponent {
this._hideDiff = this._hideDiff.bind(this);
this._showSearch = this._showSearch.bind(this);
this.state = this._initState(props, context);
- this.slotItems = [];// Array to hold
refs.
}
/**
@@ -153,230 +48,154 @@ export default class AvailableModulesMenu extends TranslatedComponent {
* @return {Object} list: Array of React Components, currentGroup Component if any
*/
_initState(props, context) {
- let translate = context.language.translate;
- let { m, warning, onSelect, ship } = props;
- let list, currentGroup;
+ const { translate } = context.language;
+ const { m, warning, onSelect, ship } = props;
+ const list = [], fuzzy = [];
+ let currentGroup;
- let buildGroup = this._buildGroup.bind(
- this,
- ship,
- translate,
- m,
- warning,
- (m, event) => {
- this._hideDiff(event);
- onSelect(m);
- }
- );
- let fuzzy = [];
- let modules = m.getApplicableItems();
- if (modules instanceof Array) {
- list = buildGroup(modules[0].grp, modules);
- } else {
- list = [];
- // At present time slots with grouped options (Hardpoints and Internal) can be empty
- if (m) {
- let emptyId = 'empty';
- if (this.firstSlotId == null) this.firstSlotId = emptyId;
- let keyDown = this._keyDown.bind(this, onSelect);
- list.push( this.slotItems[emptyId] = slotItem}>{translate('empty')}
);
- }
-
- // Need to regroup the modules by our own categorisation
- let catmodules = {};
- // Pre-create to preserve ordering
- for (let cat in CATEGORIES) {
- catmodules[cat] = [];
- }
- for (let g in modules) {
- const moduleCategory = GRPCAT[g] || g;
- const existing = catmodules[moduleCategory] || [];
- catmodules[moduleCategory] = existing.concat(modules[g]);
- }
-
- for (let category in catmodules) {
- let categoryHeader = false;
- // Order through CATEGORIES if present
- const categories = CATEGORIES[category] || [category];
- if (categories && categories.length) {
- for (let n in categories) {
- const grp = categories[n];
- // We now have the group and the category. We might not have any modules, though...
- if (modules[grp]) {
- // Decide if we need a category header as well as a group header
- if (categories.length === 1) {
- // Show category header instead of group header
- if (m && grp == m.grp) {
- list.push( this.groupElem = elem} key={category}
- className={'select-category upp'}>{translate(category)}
);
- } else {
- list.push({translate(category)}
);
- }
- } else {
- // Show category header as well as group header
- if (!categoryHeader) {
- list.push({translate(category)}
);
- categoryHeader = true;
- }
- if (m && grp == m.grp) {
- list.push( this.groupElem = elem} key={grp}
- className={'select-group cap'}>{translate(grp)}
);
- } else {
- list.push({translate(grp)}
);
- }
- }
- list.push(buildGroup(grp, modules[grp]));
- for (const i of modules[grp]) {
- let mount = '';
- if (i.mount === 'F') {
- mount = 'Fixed';
- } else if (i.mount === 'G') {
- mount = 'Gimballed';
- } else if (i.mount === 'T') {
- mount = 'Turreted';
- }
- const fuzz = { grp, m: i, name: `${i.class}${i.rating}${mount ? ' ' + mount : ''} ${translate(grp)}` };
- fuzzy.push(fuzz);
- }
- }
- }
- }
- }
+ const modules = m.getApplicableItems().map(getModuleInfo);
+ const categories = groupBy(modules, (info) => info.meta.type);
+ // Build categories sorted by translated category name
+ const sortedKeys = sortBy(Object.keys(categories), translate);
+ for (const category of sortedKeys) {
+ const catName = translate(category);
+ const infos = categories[category];
+ list.push(
+ {catName}
,
+ this._buildGroup(
+ ship,
+ m,
+ warning,
+ (m, event) => {
+ this._hideDiff(event);
+ onSelect(m);
+ },
+ category,
+ infos,
+ ),
+ );
+ fuzzy.push(
+ ...infos.map((info) => {
+ const { meta } = info;
+ const mount = meta.mount ? ' ' + translate(meta.mount) : '';
+ return {
+ grp: category,
+ m: info.proto.Item,
+ name: `${meta.class}${meta.rating}${mount} ${catName}`,
+ };
+ }),
+ );
}
- let trackingFocus = false;
- return { list, currentGroup, fuzzy, trackingFocus };
+ return { list, currentGroup, fuzzy, trackingFocus: false };
}
/**
* Generate React Components for Module Group
* @param {Ship} ship Ship the selection is for
- * @param {Function} translate Translate function
* @param {Object} mountedModule Mounted Module
* @param {Function} warningFunc Warning function
* @param {function} onSelect Select/Mount callback
- * @param {string} grp Group name
+ * @param {String} category Category key
* @param {Array} modules Available modules
* @return {React.Component} Available Module Group contents
*/
- _buildGroup(ship, translate, mountedModule, warningFunc, onSelect, grp, modules) {
- let prevClass = null, prevRating = null, prevName;
- let elems = [];
+ _buildGroup(ship, mountedModule, warningFunc, onSelect, category, modules) {
+ const classMapping = groupBy(modules, (info) => info.meta.class);
- const sortedModules = modules.sort(this._moduleOrder);
+ const itemsPerClass = Math.max(
+ ...Object.values(classMapping).map((l) => l.length),
+ );
+ const itemsPerRow = itemsPerClass <= 2 ? 6 : itemsPerClass;
+ // Nested array of elements; will be flattened before being rendered.
+ // Each sub-array represents one row in the final view.
+ const elems = [[]];
- // Calculate the number of items per class. Used so we don't have long lists with only a few items in each row
- const tmp = sortedModules.map((v, i) => v['class']).reduce((count, cls) => {
- count[cls] = ++count[cls] || 1;
- return count;
- }, {});
- const itemsPerClass = Math.max.apply(null, Object.keys(tmp).map(key => tmp[key]));
+ // Reverse sort for descending order of module class
+ for (const clazz of Object.keys(classMapping).sort().reverse()) {
+ for (let info of sortBy(
+ classMapping[clazz],
+ (info) => info.meta.mount || info.meta.rating,
+ )) {
+ const { meta } = info;
+ const { Item } = info.proto;
- let itemsOnThisRow = 0;
- for (let i = 0; i < sortedModules.length; i++) {
- let m = sortedModules[i];
- let mount = null;
- let disabled = false;
- prevName = m.name;
- if (ModuleUtils.isShieldGenerator(m.grp)) {
- // Shield generators care about maximum hull mass
- disabled = ship.hullMass > m.maxmass;
- // If the mounted module is experimental as well, we can replace it so
- // the maximum does not apply
- } else if (m.experimental && (!mountedModule || !mountedModule.experimental)) {
- disabled = 4 <= ship.hardpoints.filter(o => o.m && o.m.experimental).length;
+ // Can only be true if shieldgenmaximalmass is defined, i.e. this
+ // module must be a shield generator
+ let disabled = info.props.shieldgenmaximalmass < ship.getBaseProperty('hullmass');
+ if (meta.experimental && !mountedModule.readMeta('experimental')) {
+ disabled =
+ 4 <=
+ ship.getHardpoints().filter((m) => m.readMeta('experimental'))
+ .length;
+ }
+
+ // Default event handlers for objects that are disabled
+ let eventHandlers = {};
+ if (!disabled) {
+ const showDiff = this._showDiff.bind(this, mountedModule, info);
+ const select = onSelect.bind(null, info);
+
+ eventHandlers = {
+ onMouseEnter: this._over.bind(this, showDiff),
+ onTouchStart: this._touchStart.bind(this, showDiff),
+ onTouchEnd: this._touchEnd.bind(this, select),
+ onMouseLeave: this._hideDiff,
+ onClick: select,
+ };
+ }
+
+ const li = (
+ { this.activeSlotRef = ref; } : undefined}
+ className={cn('c', {
+ warning: !disabled && warningFunc && warningFunc(info),
+ active: mountedModule.getItem() === Item,
+ disabled,
+ })}
+ {...eventHandlers}
+ >{MOUNT_MAP[meta.mount]}{meta.class}{meta.rating}
+ );
+
+ const tail = elems.pop();
+ let newTail = [tail];
+ if (tail.length < itemsPerRow) {
+ // If the row has not grown too long, the new element can be
+ // added to the row itself
+ tail.push(li);
+ } else {
+ // Otherwise, the last row gets a line break element added and this
+ // item is put into a new row
+ tail.push(
);
+ newTail.push([li]);
+ }
+ elems.push(...newTail);
}
- let active = mountedModule && mountedModule.id === m.id;
- let classes = cn(m.name ? 'lc' : 'c', {
- warning: !disabled && warningFunc && warningFunc(m),
- active,
- disabled
- });
- let eventHandlers;
-
- if (disabled) {
- eventHandlers = {
- onKeyDown: this._keyDown.bind(this, null),
- onKeyUp: this._keyUp.bind(this, null)
-
- };
- } else {
- /**
- * Get the ids of the first and last elements in the that are focusable (i.e. are not active or disabled)
- * Will be used to keep focus inside the on Tab and Shift-Tab while it is visible
- */
- if (this.firstSlotId == null) this.firstSlotId = sortedModules[i].id;
- if (active) this.activeSlotId = sortedModules[i].id;
- this.lastSlotId = sortedModules[i].id;
-
- let showDiff = this._showDiff.bind(this, mountedModule, m);
- let select = onSelect.bind(null, m);
-
- eventHandlers = {
- onMouseEnter: this._over.bind(this, showDiff),
- onTouchStart: this._touchStart.bind(this, showDiff),
- onTouchEnd: this._touchEnd.bind(this, select),
- onMouseLeave: this._hideDiff,
- onClick: select,
- onKeyDown: this._keyDown.bind(this, select),
- onKeyUp: this._keyUp.bind(this, select)
- };
- }
-
- switch (m.mount) {
- case 'F':
- mount = ;
- break;
- case 'G':
- mount = ;
- break;
- case 'T':
- mount = ;
- break;
- }
- if (m.name && m.name === prevName) {
- // elems.push(
);
- itemsOnThisRow = 0;
- }
- if (itemsOnThisRow == 6 || i > 0 && sortedModules.length > 3 && itemsPerClass > 2 && m.class != prevClass && (m.rating != prevRating || m.mount)) {
- elems.push(
);
- itemsOnThisRow = 0;
- }
- let tbIdx = (classes.indexOf('disabled') < 0) ? 0 : undefined;
- elems.push(
- - this.slotItems[m.id] = slotItem}>
- {mount}
- {(mount ? ' ' : '') + m.class + m.rating + (m.missile ? '/' + m.missile : '') + (m.name ? ' ' + translate(m.name) : '')}
-
- );
-
- itemsOnThisRow++;
- prevClass = m.class;
- prevRating = m.rating;
- prevName = m.name;
}
- return ;
+
+ return ;
}
/**
* Generate tooltip content for the difference between the
* mounted module and the hovered modules
- * @param {Object} mm The module mounet currently
- * @param {Object} m The hovered module
+ * @param {Object} mountedModule The module mounted currently
+ * @param {Object} hoveringModule The hovered module
* @param {DOMRect} rect DOMRect for target element
*/
- _showDiff(mm, m, rect) {
+ _showDiff(mountedModule, hoveringModule, rect) {
if (this.props.diffDetails) {
this.touchTimeout = null;
- this.context.tooltip(this.props.diffDetails(m, mm), rect);
+ this.context.tooltip(
+ this.props.diffDetails(hoveringModule, mountedModule),
+ rect,
+ );
}
}
/**
* Generate tooltip content for the difference between the
* mounted module and the hovered modules
+ * @returns {React.Component} Search component if available
*/
_showSearch() {
if (this.props.modules instanceof Array) {
@@ -442,41 +261,6 @@ export default class AvailableModulesMenu extends TranslatedComponent {
this._hideDiff();
}
- /**
- * Key down - select module on Enter key, move to next/previous module on Tab/Shift-Tab, close on Esc
- * @param {Function} select Select module callback
- * @param {SyntheticEvent} event Event
- */
- _keyDown(select, event) {
- let className = event.currentTarget.attributes['class'].value;
- if (event.key == 'Enter' && className.indexOf('disabled') < 0 && className.indexOf('active') < 0) {
- select();
- return;
- }
- let elemId = event.currentTarget.attributes['data-id'].value;
- if (className.indexOf('disabled') < 0 && event.key == 'Tab') {
- if (event.shiftKey && elemId == this.firstSlotId) {
- event.preventDefault();
- this.slotItems[this.lastSlotId].focus();
- return;
- }
- if (!event.shiftKey && elemId == this.lastSlotId) {
- event.preventDefault();
- this.slotItems[this.firstSlotId].focus();
- return;
- }
- }
- }
-
- /**
- * Key Up
- * @param {Function} select Select module callback
- * @param {SytheticEvent} event Event
- */
- _keyUp(select, event) {
- // nothing here yet
- }
-
/**
* Hide diff tooltip
* @param {SyntheticEvent} event Event
@@ -487,60 +271,12 @@ export default class AvailableModulesMenu extends TranslatedComponent {
this.context.tooltip();
}
- /**
- * Order two modules suitably for display in module selection
- * @param {Object} a the first module
- * @param {Object} b the second module
- * @return {int} -1 if the first module should go first, 1 if the second module should go first
- */
- _moduleOrder(a, b) {
- // Named modules go last
- if (!a.name && b.name) {
- return -1;
- }
- if (a.name && !b.name) {
- return 1;
- }
- // Class ordered from highest (8) to lowest (1)
- if (a.class < b.class) {
- return 1;
- }
- if (a.class > b.class) {
- return -1;
- }
- // Mount type, if applicable
- if (a.mount && b.mount && a.mount !== b.mount) {
- if (a.mount === 'F' || (a.mount === 'G' && b.mount === 'T')) {
- return -1;
- } else {
- return 1;
- }
- }
- // Rating ordered from highest (A) to lowest (E)
- if (a.rating < b.rating) {
- return -1;
- }
- if (a.rating > b.rating) {
- return 1;
- }
- // Do not attempt to order by name at this point, as that mucks up the order of armour
- return 0;
- }
-
/**
* Scroll to mounted (if it exists) module group on mount
*/
componentDidMount() {
- if (this.groupElem) { // Scroll to currently selected group
- this.node.scrollTop = this.groupElem.offsetTop;
- }
- /**
- * Set focus on active or first slot element, if applicable.
- */
- if (this.slotItems[this.activeSlotId]) {
- this.slotItems[this.activeSlotId].focus();
- } else if (this.slotItems[this.firstSlotId]) {
- this.slotItems[this.firstSlotId].focus();
+ if (this.activeSlotRef) {
+ this.activeSlotRef.focus();
}
}
@@ -570,10 +306,10 @@ export default class AvailableModulesMenu extends TranslatedComponent {
render() {
return (
this.node = node}
- className={cn('select', this.props.className)}
- onScroll={this._hideDiff}
- onClick={(e) => e.stopPropagation()}
- onContextMenu={stopCtxPropagation}
+ className={cn('select', this.props.className)}
+ onScroll={this._hideDiff}
+ onClick={(e) => e.stopPropagation()}
+ onContextMenu={stopCtxPropagation}
>
{this._showSearch()}
{this.state.list}