This post is really more for my own edification than anything else. I’ve been spending many hours these past few days trying to get this community site database and API working, with limited front-end support as a “proof of concept” that I wasn’t completely off the rails in my thinking. If you’ve ever done development, art, writing, knitting, or production of any kind of anything, then I hope you’ll agree that being “in the zone” is like stepping into another dimension where time has no meaning. Some of the ideas I came up with over the past few days seemed to issue forth like Athena from the skull of Zeus, fully formed and ready for battle, and it wasn’t until the concepts were tested that I had to apply the brakes and return to reality to see what kind of a mess I had created.

As stated in my previous two posts, I’ve been trying to work on “domain data” for this website. While a lot of dynamic content is entered free form, there are going to be times when aspects of that dynamic data need to fall into very specific buckets. As this is a Star Citizen community site, we don’t want someone cataloging a mineral mining location as “corgi” because that makes absolutely no sense. The solution is to sometimes provide drop down lists or list boxes filled with a limited number of options that apply to a specific use case. Some of the features we’re adding to this site are location-based, so I thought it might make sense to start with a way to offer selections representing locations in the Star Citizen universe.

The Galaxy Thus Far

ARK Starmap | Roberts Space Industries | Follow the development of Star Citizen and Squadron 42

Here’s a representation of the only star system we have in the game right now: Stanton. There are four planets, each with a variety of moons. There are a few stations in orbit around the planets as well, one “rogue station” for criminals, and a few points of interest centered around the planet Crusader. On the planets and moons there are various cities, outposts, derelict settlements, crashed ships, and other places to visit. Currently, cities and stations offer amenities like shops and ASOP terminals for summoning ships, while many outposts, derelicts, and crashes are flashpoints for various dynamic missions. I don’t know how many places there are in total in Stanton, but Hurston alone has maybe close to two dozen named locations by itself.

Ultimately, we’ll be wanting to get down to a granular level if a location is worthy of providing something to the players. For example, HDMS Bezdek on Hurston is a short hop from the city of Lorville, and from Everus Harbor in orbit. Bezdek is a good place to land to spawn ground vehicles to load into larger ships. Even right now at this stage in development, each shop at each location has its own inventory, making it crucial for players to know where they can obtain specific weapons, ammo, armor, and utilities.

Starting at the solar system level and allowing a user to drill down to an individual shop level is not an insignificant task. First, that’s going to be a lot of data when the game’s galaxy is completed. Second, there’s no intrinsic hierarchy in the game, really. Stanton contains Hurston, which contains Lorville, which contains Tammany & Sons arms and armor shop, but there’s also Everus Harbor in orbit around Hurston, and the dozens of outposts. Not every level has specific and repeated designations, meaning that we could have a station that’s a child of a planet, and another that’s a child of a moon of that same planet.

Representing this in Data

STANTON
   \__ Hurston
         \__ Everus Harbor
               \__ [Various shops]
               \__ [ADMIN office (for missions)]
         \__ Lorville
               \__ [Various shops]
               \__ [Medical facilities]
               \__ [ASOP terminals]
               \__ [New Deal ship seller]
               \__ [Ship rental counter]
               \__ [HD ship weapons shop]
               \__ [Trade terminals]
         \__ HDMS Bezdek
               \__ [ASOP terminals for ground vehicles]
               \__ [Trade terminals]
         \__ [Other places on Hurston]
         \__ [Various Moons, each with their own outposts and POI]

Organizing data in threads is not new; it’s the default purpose of a “relational database”, really, where it’s known as a “one to many” relationship. In the partial diagram above, we see Stanton > Hurston > various locations. We cannot talk about Hurston without recognizing its place in the galaxy as a child of Stanton, and we would never say “you can buy that armor at Garrity Defense” without narrowing it down to “at Port Olisar” because Garrity Defense might have several outlets throughout the galaxy but only one specific location might have what a player wants.

To that end, the “zones” table is organized with a primary key ID and a foreign key Parent_ID.

When properly threaded (as per the previous post), we can see a hierarchical structure of which locations “belong” to which locations.

With the data now available to the client, the real hard part can begin: making it make sense to the user.

Managing Data

Domain tables aren’t static; we know that there are many more solar systems on the way in the future of Star Citizen. It wouldn’t be very effective if the only way to add new data to the database was through the DB management UI because not only would it be a PITA to generate those IDs, but we don’t want people to be mucking around in the database itself.

I’ve created an ugly working example of a location management UI to test the concepts I want to employ going forward. These UIs will allow designated users to manage the data that organizes other data throughout the site.

This simple list represents the data I’ve added by hand so far. The indents represent our hierarchy, with Stanton at the top of the stack, followed by mostly planets, some of which have their own child locations. The second column lists the jurisdiction within that zone, which is important for reputation tracking as well as criminal activity tracking. The third column is the “category” of location.

Currently, adding a new location can only be done if the location is a child of another location. Since everything we know is a child of Stanton, it’s not a big deal right now.

Here we are adding a new child to Lorville, the weapons store Tammany & Sons. I’m listing it as an independent jurisdiction, even though Lorville is an armistice zone and jurisdiction doesn’t mean much at street level. It’s classified as a “shop”, and when I click on add, the new entry is saved as a child of Lorville.

In taking screenshots, I noticed that Lorville is misspelled (I swear this was not intentional). Managing data isn’t just about adding new entries; we’re going to need to fix or update existing data as well. To do this, I click on the name of the zone.

I make the changes to the name of the zone (and could change the jurisdiction and category if I needed to) and click Save.

The Code

For testing, I created all components in the same file. I’m of two minds about this; a single file makes everything easy to conceptualize, but individual files are easier to focus on. I haven’t decided if I’ll break out each component into its own file, or just leave them be.

The first component is AdminZoneList. This is the “master component” which serves as the base for hosting the other components.

const AdminZoneList = (props) =>{
    const [ zones, setZones ] = useState([])
    const [ refreshToken, setRefreshToken ] = useState(uuidv4())

    const categories = useCategoriesList('ZONE');

    //Idea: When refreshToken changes, it will force this to update
    //  and will force the reload of zones in state. 
    //Hopefully this will fire on first load, as refreshToken is
    //  updated with the UUID on component load/refresh
    useEffect(()=>{
        GetZoneFamily()
    },[refreshToken])

    const GetZoneFamily = () => {
        axiosBase.get('[GET_ZONES]')
        .then((results)=>{
            if (results.status === 200){
                setZones(results.data.Payload);
            }else{
                setZones([]);
                console.log(results.data.TechnicalMessage);
            }
        })
        .catch((err)=>{
            console.log(err);
        })
    }

    let indent = 0;

    return(
        <table>
            <tbody>
                <tr><td colSpan={20}>Add New will go here</td></tr>
                {
                    zones?.map((itm)=>{
                        return <ZoneEntry key={itm.ID} categories={categories} onSave={()=>setRefreshToken(uuidv4())} zone={itm} indent={indent} />
                    })
                }
            </tbody>
        </table>
    )
}

Zones is the state bucket which holds the data we load via the function GetZoneFamily. The result of the API call is the nested location list I had so much trouble putting together. refreshToken is a hack; it stores a GUID that is regenerated every time a record is added or edited. Using it as an observable variable, when it changes it triggers the useEffect hook which calls GetZoneFamily and refreshes the overall list on the page.

The useCategoriesList(‘ZONE’) assignment is a custom Hook which queries the GetCategories endpoint. It takes an argument type which allows me to filter categories. In this case I only want “zones”, but in other cases I can foresee wanting only “personal weapons”, “personal armor”, or “ship components”. This list is passed down the component chain for drop down list use.

The return code iterates over the zones state collection, and for each location, emits a single record display ZoneEntry. Of note is the variable indent, which is another hack which helps create the visual structure of the hierarchy on the page.

const ZoneEntry = (props) =>{
    const [ isEdit, setIsEdit ] = useState(false);

    const { zone, categories } = props;
    const { onSave } = props;

    const onEdit = () => {setIsEdit(true)}

    const onSaveEdit = () =>{
        setIsEdit(false)
        onSave()
    }

    const onCancel = () => {setIsEdit(false)}

    let _indent = props.indent + 1

    return(
        isEdit
        ? <ZoneEdit zone={zone} categories={categories} onSave={onSaveEdit} onCancel={onCancel} indent={_indent} />
        : <ZoneDisplay zone={zone} categories={categories} onSave={onSave} onEdit={onEdit} indent={_indent} />
    )
}

ZoneEntry accepts zone, which contains data for a single location. It also accepts a dataset categories, which we saw in the previous component. onSave is a handler from the parent that is used to refresh the main list and is passed all the way down the component chain.

isEdit is a state bucket that is used as a state switch. When a user clicks on the name of a location, this value is set to true. In the return statement, we display either ZoneEdit or ZoneDisplay based on the value of this switch. The onCancel handler sets the isEdit to false when the user clicks the Cancel button. Finally, the value of indent is incremented here; because we’re going back into recursion territory, we’ll see that this value increases every time we add a new child but reverts to a parent value for every sibling.

const ZoneDisplay = (props) => {
    const [ isAdd, setIsAdd ] = useState(false);

    const { zone, categories, indent } = props;
    const { onSave, onEdit } = props;

    const onAddNew = () => { setIsAdd(true); }
    const onSaveAdd = () => { setIsAdd(false); onSave(); }
    const onCancel = () => { setIsAdd(false) }


    return(
        <React.Fragment>
            <tr key={zone.ID}>
                <td style={{paddingLeft:`${indent * 10}px`}}><a href="#" onClick={onEdit}>{zone.Zone}</a></td>
                <td>{zone.Jurisdiction}</td>
                <td>{zone.Category}</td>
                <td><button name="Add" onClick={onAddNew}>Add Child</button></td>
            </tr>
            {
                isAdd
                ? <ZoneAdd parentId={zone.ID} categories={categories} onSave={onSaveAdd} onCancel={onCancel} />
                : null
            }
            {
                zone.ChildZones?.map((itm)=>{
                    return <ZoneEntry key={itm.ID} categories={categories} onSave={onSave} zone={itm} indent={indent} /> 
                })
            }
        </React.Fragment>
    )
}

ZoneDisplay simply shows record data. It’s passed in from ZoneEntry, which receives it from AdminZoneList‘s map iterative function. We also receive other properties from ZoneEntry like categories and indent. Two function handlers, onSave and onEdit are passed in as well. The first is handled at the top component to refresh the master display list, while the second is used to flip the bit between display and editing at the parent level.

In addition to basic display, this component has two potential states. The first is to display the new record component ZoneAdd in response to the value of the isAdd state bucket. The second is to iterate over location records stored in the current zone.ChildZones collection. These are the nested location records which are displayed by recursively passing each child location record to another instance of the ZoneEntry component.

const ZoneEdit = (props) => {
    const [ zoneEdit, setZoneEdit ] = useState(props.zone);

    const { categories } = props;
    const { onSave, onCancel } = props;

    const onChange = (e) =>{
        const name = e.target.name;
        const value = e.target.value;

        setZoneEdit(prev =>({
            ...prev,
            [name]:value
        }))
    }

    const onEdit = () =>{
        const z = {
           id: zoneEdit.ID,
           pid: zoneEdit.Parent_ID,
           znm: zoneEdit.Zone,
           ztp: zoneEdit.Category_ID,
           jdc: zoneEdit.Jurisdiction
        }
        axiosBase.post('[EDIT ZONE]', queryString.stringify(z))
        .then((results)=>{
            onSave()
        })
        .catch((err)=>{})
    }

    return(
        <React.Fragment>
            <tr key={zoneEdit.ID}>
                <td><input type="text" name="Zone" value={zoneEdit.Zone} onChange={onChange} /></td>
                <td><input type="text" name="Jurisdiction" value={zoneEdit.Jurisdiction} onChange={onChange} /></td>
                <td>
                    <select name="Category_ID" value={zoneEdit.Category_ID} selected={zoneEdit.Category_ID} onChange={onChange}>
                        {categories}
                    </select>
                </td>
                <td><button onClick={onEdit}>Save</button></td>
                <td><button onClick={onCancel}>Cancel</button></td>
            </tr>
            {
                zoneEdit?.ChildZones?.map((itm)=>{
                    return <ZoneEntry key={itm.ID} onSave={onSave} zone={itm} /> 
                })
            }
        </React.Fragment>
    )
}

ZoneEdit allows us to modify an existing record. I tried to make this self-contained, so I didn’t have to pass values all the way up the component stack, but in doing so I have violated one of the core tenets of React: don’t put a prop value into a state bucket! However, my reason is sound: the prop value is the current value from the original list, and we are editing that value. Placing the prop in state allows me to deploy controlled inputs which, when they receive changes, updates that state bucket. Usually placing a prop in state decouples the prop’s refresh cycle from the data we expect to be working with at any given time, since the data exists in two places now. Here, that’s OK. If the page refreshes before the edit completes, I want the props value to override state, and I want state to override props as the user edits the record.

When the user clicks the save button, we send the contents of the state bucket to the API endpoint to update that record.

Here, we also iterate over the zone record’s ChildZones contents to display other ZoneEntry components.

const ZoneAdd = (props) => {
    const [ newZone, setNewZone ] = useState([])

    const { parentId, categories } = props;
    const { onSave, onCancel } = props;

    const onAdd = () => {
        const z = {
            pid: parentId,
            znm: newZone.Zone,
            ztp: newZone.Category_ID,
            jdc: newZone.Jurisdiction
         }
         axiosBase.post('[ADD ZONE]', queryString.stringify(z))
         .then((results)=>{
             onSave()
         })
         .catch((err)=>{})
    }

    const onChange = (e) =>{
        const name = e.target.name;
        const value = e.target.value;

        setNewZone(prev =>({
            ...prev,
            [name]:value
        }))
    }

    return(
        <React.Fragment>
            <tr>
                <td><input type="text" name="Zone" value={newZone.Zone} onChange={onChange} /></td>
                <td><input type="text" name="Jurisdiction" value={newZone.Jurisdiction} onChange={onChange} /></td>
                <td>
                    <select name="Category_ID" value={newZone.Category_ID} selected={newZone.Category_ID} onChange={onChange}>
                        {categories}
                    </select>
                </td>
                <td><button onClick={onAdd}>Add</button></td>
                <td><button onClick={onCancel}>Cancel</button></td>
            </tr>
        </React.Fragment>
    )

}

Finally, we have ZoneAdd which operates much like ZoneEdit with the exception that we don’t carry a zone record in from the parent. We only need to know if this is a child of an existing zone, and since children are all we can create at this time, we pass the parent’s ID through to this component via the parentId prop.

Going Forward

I still need to adapt ZoneAdd to allow for a new, non-child location which shouldn’t be difficult, as a non-child location has a parentId value of “0” currently. Forcing this at the top level of the form would allow for me to start adding other star systems.

More importantly, I want to add a search or filter system. As mentioned earlier, as time goes on and Star Citizen stars adding more locations (or just as I manage to start including more in my test data), finding a particular location is going to be difficult if I’m forced to scroll through an ever-growing list. I had this working in an earlier iteration, as all I need to do is apply an Array.filter to the original result set.

There are some obvious (if you use React) violations and things that “could be done better”. For example, I am passing a lot of data through props, some of it defined at the very top level. This would be a good case for a context, maybe, as the idea is to not have every single component re-perform the same operation (especially in the case of useCategoryList).

There are a few potential changes I could make to this system, and which might come into play when this data reaches a public facing UI. A user wouldn’t normally need to see all locations all at once. Ideally a user would choose a starting point like the star system that they could choose from a list of only star systems. Upon selection, we’d roll down a panel listing the immediate children of that parent location. Then, as the user drills down location by location, we only ever have to load and display the children of their previous selection. Also, I’m going to need to inact a more robust search mechanism, so that a user could enter, say “armor shops on microTech” or “where can I find [SPECIFIC BRAND] of quantum engine?” and have the correct locations display.

Scopique

Owner and author.

3 Comments

  • Tipa

    September 27, 2022 - 11:07 AM

    I wonder if this is just too complicated. What if every place you could BE — at a shop, in orbit around a planet, at a station, at a bar in that station — was a node, that shared connections with other nodes. Children would be implied by connections, but they wouldn’t be hierarchical connections, but rather edge connections. So you’re in orbit around Hurston. You can connect to a city, a space station, or other planets. You follow the edge to Loreville — you land at a city. Now your options are to return to Hurston orbit, go to some place in Loreville, etc. Flat hierarchy means you can put whatever in the database without worrying about containers.

    It also allows you to do pathfinding more easily than with the b-tree setup you’re making. For instance, it would be pretty simple to take into account nodes that might be open to connections only at specific times — shops that close, wormholes or whatever that may only be open at specific intervals, just examples. But mostly, it means you never have to worry about assigning hierarchies.

    • Scopique

      September 27, 2022 - 11:14 AM

      That’s a pretty good take. I assume we’d still need to categorize things, so we know the difference between an outpost and a shop. The hierarchy idea came from the idea of “alerts”, so that if someone was jumped at an outpost on Hurston, if anyone found themselves at A) Hurston (planet), B) Everus Harbor (Station around Hurston), or that specific outpost, we could “bubble up” the alert X number of levels. If someone is over at ArcCorp, they don’t need to know what’s happening at Hurston. Although with the “node” design, we could still say “only alert those people who are X nodes away”. That could work.

      Only issue is that in talking with the org, everyone has it in their head that there’s a hierarchy, so if it came down to giving someone authority to manage this domain data, they’d have to think of it more in terms of “neighbors” and less like parent-child.

  • Tipa

    September 27, 2022 - 6:02 PM

    Undirected weighted graphs are just so, so useful.

Leave a Reply

Your email address will not be published. Required fields are marked *