Skip to content

Commit

Permalink
Officer UI (#309)
Browse files Browse the repository at this point in the history
* make initial BubbleChart Component UI

not integrated with API, just visuals

* Work on officer display table UI

* Add height prop to BubbleChart

* Integrate BubbleChart into Officer Search View

* Update snapshots

* Revert Map Screenshot

---------

Co-authored-by: abelaba <[email protected]>
Co-authored-by: Darrell Malone Jr <[email protected]>
  • Loading branch information
3 people authored Dec 20, 2023
1 parent f8f2b3a commit 0488d03
Show file tree
Hide file tree
Showing 26 changed files with 612 additions and 150 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

.backgroundBanner {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: 1fr auto 0.3fr;
min-width: 100%;
position: absolute;
background-color: var(--lightBlue);
Expand Down
14 changes: 2 additions & 12 deletions frontend/compositions/dashboard-header/dashboard-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import useDropdownMenu from "react-accessible-dropdown-menu-hook"
import { useMediaQuery } from "react-responsive"
import { AuthContext } from "../../helpers/auth"
import { LogoSizes } from "../../models"
import { Logo as NPDCLogo } from "../../shared-components"
import { Logo as NPDCLogo, PrimaryButton } from "../../shared-components"
import styles from "./dashboard-header.module.css"
import MobileDropdown from "./mobile-dropdown"
import Nav from "./nav"
Expand All @@ -23,17 +23,6 @@ export default function DashboardHeader() {
<div className={titleContainer}>
<h2>National Police Data Coalition</h2>
<p>The national index of police incidents</p>
<button
className="primaryButton"
style={{
backgroundColor: "white",
color: "#303463",
border: "#303463 thin solid",
fontWeight: "bold",
margin: "2rem 2rem 0 0"
}}>
Donate
</button>
</div>
</div>
{/* Only show the buttons if the user is logged in */}
Expand All @@ -46,6 +35,7 @@ export default function DashboardHeader() {
)}
</nav>
)}
<PrimaryButton>Donate</PrimaryButton>
</div>
</header>
)
Expand Down
3 changes: 2 additions & 1 deletion frontend/compositions/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import SavedSearches from "./profile-saved-tables/saved-searches"
import ProfileType from "./profile-type"
import { SearchPanel } from "./search-panel/search-panel"
import SearchResultsTable from "./search-results"
import { Map } from "./visualizations"
import { Map, BubbleChart } from "./visualizations"

export {
DashboardHeader,
Expand All @@ -24,6 +24,7 @@ export {
EnrollmentHeader,
LandingPage,
Map,
BubbleChart,
PassportApplicationResponse,
PasswordAid,
ProfileInfo,
Expand Down
16 changes: 11 additions & 5 deletions frontend/compositions/search-panel/search-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useState } from "react"
import { FormProvider, useForm } from "react-hook-form"

import { useAuth, useSearch } from "../../helpers"
import { searchPanelInputs, SearchTypes, ToggleOptions } from "../../models"
import { IToggleOptions, searchPanelInputs, SearchTypes } from "../../models"
import {
FormLevelError,
PrimaryButton,
Expand All @@ -12,20 +12,25 @@ import {
} from "../../shared-components"
import styles from "./search.module.css"
import SecondaryInputStories from "../../shared-components/secondary-input/secondary-input.stories"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
import { faEdit } from "@fortawesome/free-solid-svg-icons"

const { searchPanelContainer, searchForm } = styles

export const SearchPanel = () => {
interface SearchPanelProps {
toggleOptions: IToggleOptions[]
setToggleOptions: Function
}

export const SearchPanel = (props: SearchPanelProps) => {
const form = useForm()
const { searchIncidents } = useSearch()
const { accessToken } = useAuth()

const [errorMessage, setErrorMessage] = useState("")
const [formInputs, setFormInputs] = useState(searchPanelInputs.incidents)
const [isLoading, setIsLoading] = useState(false)
const [toggleOptions, setToggleOptions] = useState(
new ToggleOptions("incidents", "officers").options
)
const { toggleOptions, setToggleOptions } = props

const toggleFormInputs = (e: any) => {
const updatedToggleOptions = toggleOptions.map(({ type, value }) => {
Expand Down Expand Up @@ -65,6 +70,7 @@ export const SearchPanel = () => {
</fieldset>
{errorMessage && <FormLevelError errorId="ErrorMessage" errorMessage={errorMessage} />}
<PrimaryButton loading={isLoading} type="submit">
<FontAwesomeIcon icon={faEdit} />
Search
</PrimaryButton>
</form>
Expand Down
2 changes: 1 addition & 1 deletion frontend/compositions/search-panel/search.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-evenly;
/* justify-content: space-evenly; */
width: var(--size384);
height: 60vh;
}
16 changes: 8 additions & 8 deletions frontend/compositions/search-results/search-results.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { faSlidersH } from "@fortawesome/free-solid-svg-icons"
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"

import { useSearch } from "../../helpers"
import { Perpetrator } from "../../helpers/api"
import { Incident, Officer, Perpetrator, Rank } from "../../helpers/api"
import { formatDate } from "../../helpers/syntax-helper"
import { TooltipIcons, TooltipTypes } from "../../models"
import { InfoTooltip } from "../../shared-components"
Expand All @@ -13,16 +13,16 @@ import {
GreaterThanButton
} from "../../shared-components/icon-buttons/icon-buttons"

export default function SearchResultsTable() {
const {
incidentResults: { results }
} = useSearch()

if (results.length === 0) return <div>No results</div>
interface SearchProps {
results: Incident[] | Officer[]
resultsColumns: Column<any>[]
}

export default function SearchResultsTable(searchProps: SearchProps) {
const { results, resultsColumns } = searchProps
return (
<>
{!!results.length ? (
{!!searchProps.results.length ? (
<DataTable tableName={"Search Results"} columns={resultsColumns} data={results} />
) : (
<p>No Results</p>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import React from "react"
import { ComponentMeta, ComponentStory } from "@storybook/react"
import BubbleChart from "./BubbleChart"

export default {
title: "Visualizations/BubbleChart",
component: BubbleChart
} as ComponentMeta<typeof BubbleChart>

const Template: ComponentStory<typeof BubbleChart> = () => <BubbleChart height={500} />

export const Default = Template.bind({})
189 changes: 189 additions & 0 deletions frontend/compositions/visualizations/bubble-chart/BubbleChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
import * as d3 from "d3"
import { useEffect, useRef } from "react"
import styles from "./bubble-chart.module.css"

interface BubbleData {
officerName: string
location: string
incidents: number
}

interface BubbleNode extends BubbleData {
children?: BubbleNode[]
}

interface BubbleChartProps {
height?: number
}

export default function BubbleChart({ height }: BubbleChartProps) {
const svgRef = useRef<SVGSVGElement>(null)

const updateSVG = () => {
const svg = d3.select(svgRef.current)

const root = d3.hierarchy<BubbleNode>(bubbleTree)
const pack = d3.pack<BubbleNode>().size([300, 300]).padding(5)

root.sum((d) => d.incidents || 0).sort((a, b) => b.data.incidents - a.data.incidents)
const packedHierarchy = pack(root)

const colorScale = d3
.scaleOrdinal<string>()
.domain(packedHierarchy.leaves().map((d) => d.data.officerName))
.range(["#BBDDF8", "#7CAED7", "#303463"])

svg.selectAll("*").remove()

// Add drop shadow to SVG
svg
.append("defs")
.append("filter")
.attr("id", "drop-shadow")
.attr("width", "150%")
.attr("height", "150%")
.append("feDropShadow")
.attr("dx", 0)
.attr("dy", 2)
.attr("stdDeviation", 1)
.attr("flood-color", "rgba(0,0,0,0.5)")

// Add bubbles to SVG
svg
.selectAll("circle")
.data(packedHierarchy.leaves())
.enter()
.append("circle")
.attr("cx", (d) => d.x)
.attr("cy", (d) => d.y)
.attr("r", (d) => d.r)
.attr("fill", (d) => colorScale(d.data.officerName))
.attr("opacity", 0.7)
.style("filter", "url(#drop-shadow)")

// Add bubble text to SVG
svg
.selectAll("foreignObject")
.data(packedHierarchy.leaves())
.enter()
.append("foreignObject")
.attr("width", (d) => d.r * 2)
.attr("height", (d) => d.r * 2)
.html(({ data, r }) => renderBubbleText(data, r, colorScale))
.attr("x", (d) => d.x - d.r)
.attr("y", (d) => d.y - d.r)
.attr("font-size", "11px")

// Adjust viewBox to fit the entire graphic
const minX = d3.min(packedHierarchy.leaves(), (d) => d.x - d.r)
const minY = d3.min(packedHierarchy.leaves(), (d) => d.y - d.r)
const width = d3.max(packedHierarchy.leaves(), (d) => d.x + d.r - minX)
const height = d3.max(packedHierarchy.leaves(), (d) => d.y + d.r - minY)

svg.attr("viewBox", `${minX - 5} ${minY - 5} ${width + 10} ${height + 10}`)
}

useEffect(() => {
updateSVG()
}, [])

return <svg ref={svgRef} height={height} className={styles.svg} />
}

const renderBubbleText = (
data: BubbleNode,
r: number,
colorScale: d3.ScaleOrdinal<string, string, never>
) => {
const dummy = document.createElement("div")
const circleColor = colorScale(data.officerName)
const fillColor = circleColor === "#303463" ? "white" : "black"

const bubbleText = document.createElement("div")
bubbleText.style.display = "flex"
bubbleText.style.flexDirection = "column"
bubbleText.style.justifyContent = "center"
bubbleText.style.alignItems = "center"
bubbleText.style.height = "100%"
bubbleText.style.color = fillColor

// Officer Name Element
const officerName = document.createElement("tspan")
officerName.textContent = data.officerName
officerName.style.fontWeight = "bold"
officerName.style.textAlign = "center"

// Location Element
const location = document.createElement("tspan")
location.textContent = data.location

// Incident Element
const incidents = document.createElement("tspan")
incidents.textContent = `${data.incidents} Incidents`

bubbleText.append(officerName)
bubbleText.append(location)
bubbleText.append(incidents)

dummy.append(bubbleText)

return r > 40 ? dummy.innerHTML : ""
}

const bubbleTree: BubbleNode = {
officerName: "Root",
location: "",
incidents: 0,
children: [
{
officerName: "Sgt. Jason Smith",
location: "New York PD",
incidents: 846
},
{
officerName: "Lt. Jason Smith",
location: "Seattle PD",
incidents: 92
},
{
officerName: "Sgt. Jason Smith",
location: "Hoover Sherriff",
incidents: 8
},
{
officerName: "Cpt. Jason Smith",
location: "Tampa PD",
incidents: 742
},
{
officerName: "Dep. Jason Smith",
location: "Honolulu PD",
incidents: 12
},
{
officerName: "Cpt. Sarah Johnson",
location: "Chicago PD",
incidents: 950
},
{
officerName: "Sgt. Emily Davis",
location: "Los Angeles PD",
incidents: 347
},
{
officerName: "Lt. Michael Brown",
location: "Dallas PD",
incidents: 580
},
{
officerName: "Dep. Amanda White",
location: "Miami PD",
incidents: 49
},
{
officerName: "Cpt. Robert Miller",
location: "Phoenix PD",
incidents: 15
}
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.svg {
width: 100%;
}
5 changes: 5 additions & 0 deletions frontend/compositions/visualizations/bubble-chart/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import dynamic from "next/dynamic"

// d3 does not support server-side rendering, so only render the Map component
// in the browser.
export default dynamic(() => import("./BubbleChart"), { ssr: false })
3 changes: 2 additions & 1 deletion frontend/compositions/visualizations/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Map from "./map"
import BubbleChart from "./bubble-chart"
import useSearchData from "./map/useSearchData"

export { Map, useSearchData }
export { Map, useSearchData, BubbleChart }
19 changes: 19 additions & 0 deletions frontend/helpers/api/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,28 @@ export interface Perpetrator {
last_name?: string
}

export enum Rank {
TECHNICIAN = "Technician",
OFFICER = "Officer",
DETECTIVE = "Detective",
CORPORAL = "Corporal",
SERGEANT = "Sergeant",
LIEUTENANT = "Lieutenant",
CAPTAIN = "Captain",
DEPUTY = "Deputy",
CHIEF = "Chief"
}

export interface Officer {
id?: number
first_name?: string
last_name?: string
race?: string
ethnicity?: string
gender?: string
rank?: Rank
star?: string
date_of_birth?: Date
}

export interface UseOfForce {
Expand Down
Loading

0 comments on commit 0488d03

Please sign in to comment.