Last active
February 23, 2025 14:03
-
-
Save kimji1/7e07e2251f0ddb8d4149e82689ab6b6c to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@Composable | |
fun HorizontalStackedBarChart( | |
modifier: Modifier = Modifier, | |
textMeasurer: TextMeasurer, | |
state: HorizontalStackedBarState, | |
legendState: LegendState? = null, | |
) { | |
fun elementSpaceCount(elements: List<HorizontalStackedBarElement>): Int = | |
elements.count { it.useSpace } | |
val chartTotalHeight by remember { mutableFloatStateOf(state.getTotalHeight(textMeasurer)) } | |
Column( | |
modifier = Modifier | |
.fillMaxWidth() | |
.height(chartTotalHeight.toDp()) | |
.then(modifier) | |
) { | |
Box( | |
modifier = Modifier | |
.fillMaxWidth() | |
.height(state.chartHeight.toDp()) | |
) { | |
HorizontalStackedBar( | |
modifier = Modifier.align(Alignment.Center), | |
state = state, | |
elementSpaceCount = ::elementSpaceCount | |
) | |
val vectorPainterState = state.vectorPainterState | |
if (vectorPainterState != null) { | |
HorizontalStackedBarIcon( | |
modifier = Modifier.fillMaxSize(), | |
state = state, | |
vectorPainterState = vectorPainterState, | |
elementSpaceCount = ::elementSpaceCount | |
) | |
} | |
} | |
HorizontalStackedBarName( | |
textMeasurer = textMeasurer, | |
modifier = Modifier.fillMaxSize(), | |
state = state, | |
elementSpaceCount = ::elementSpaceCount | |
) | |
} | |
if (legendState != null) { | |
Legend( | |
modifier = legendState.modifier, | |
legendState = legendState | |
) | |
} | |
} | |
@Composable | |
fun HorizontalStackedBar( | |
modifier: Modifier = Modifier, | |
state: HorizontalStackedBarState, | |
elementSpaceCount: (List<HorizontalStackedBarElement>) -> Int, | |
) { | |
val cornerRadius = state.barHeight / 2 | |
Canvas( | |
modifier = Modifier | |
.fillMaxWidth() | |
.height(state.barHeight.toDp()) | |
.clip(RoundedCornerShape(cornerRadius)) | |
.then(modifier) | |
) { | |
val elements = state.normalizedElements | |
val elementTotalWidth = size.width - (state.elementSpace * elementSpaceCount(elements)) | |
val unit = elementTotalWidth / state.normalizedBase | |
elements.forEachIndexed { idx, element -> | |
val spaceCount = elementSpaceCount(elements.subList(0, idx + 1)) | |
val stackedWidth = element.min * unit + max(0f, spaceCount * state.elementSpace) | |
val elementWidth = max(element.base / 100f, element.max - element.min) * unit | |
drawHorizontalStackedBar( | |
currentIdx = idx, | |
lastIndex = elements.lastIndex, | |
barHeight = state.barHeight, | |
cornerRadius = cornerRadius, | |
stackedWidth = stackedWidth, | |
elementWidth = elementWidth, | |
topPadding = 0f, | |
color = element.color | |
) | |
} | |
} | |
} | |
fun DrawScope.drawHorizontalStackedBar( | |
currentIdx: Int, | |
lastIndex: Int, | |
barHeight: Float, | |
cornerRadius: Float, | |
stackedWidth: Float, | |
elementWidth: Float, | |
topPadding: Float, | |
color: Color | |
) { | |
val path = Path().apply { | |
val xPos = if (stackedWidth < cornerRadius) 0f else stackedWidth | |
val width = if (stackedWidth < cornerRadius) elementWidth + stackedWidth else elementWidth | |
val radius = CornerRadius(cornerRadius, cornerRadius) | |
addRoundRect( | |
RoundRect( | |
rect = Rect( | |
offset = Offset(x = xPos, y = topPadding), | |
size = size.copy(width = width, height = barHeight), | |
), | |
topLeft = if (currentIdx == 0) radius else CornerRadius.Zero, | |
topRight = if (currentIdx == lastIndex) radius else CornerRadius.Zero, | |
bottomLeft = if (currentIdx == 0) radius else CornerRadius.Zero, | |
bottomRight = if (currentIdx == lastIndex) radius else CornerRadius.Zero | |
) | |
) | |
} | |
drawPath(path, color = color) | |
} | |
@Composable | |
fun HorizontalStackedBarName( | |
textMeasurer: TextMeasurer, | |
modifier: Modifier = Modifier, | |
state: HorizontalStackedBarState, | |
elementSpaceCount: (List<HorizontalStackedBarElement>) -> Int, | |
) { | |
Canvas(modifier = modifier) { | |
val elements = state.normalizedElements | |
val elementTotalWidth = size.width - (state.elementSpace * elementSpaceCount(elements)) | |
val unit = elementTotalWidth / state.normalizedBase | |
elements.forEachIndexed { idx, element -> | |
val spaceCount = elementSpaceCount(elements.subList(0, idx + 1)) | |
val stackedWidth = element.min * unit + max(0f, spaceCount * state.elementSpace) | |
val elementWidth = max(0.1f, element.max - element.min) * unit | |
drawBarName( | |
textMeasurer = textMeasurer, | |
element = element, | |
stackedWidth = stackedWidth, | |
elementWidth = elementWidth, | |
nameTextStyle = state.elementNameTextStyle, | |
nameYPos = state.elementNameTopPadding, | |
) | |
} | |
} | |
} | |
fun DrawScope.drawBarName( | |
textMeasurer: TextMeasurer, | |
element: HorizontalStackedBarElement, | |
elementWidth: Float, | |
stackedWidth: Float, | |
nameTextStyle: TextStyle, | |
nameYPos: Float | |
) { | |
element.barName ?: return | |
val textSize = textMeasurer.measure( | |
text = element.barName.text, | |
style = nameTextStyle | |
).size | |
val alignmentOffset = when (element.barName.alignment) { | |
HorizontalStackedBarNameAlignment.Start -> 0f | |
HorizontalStackedBarNameAlignment.Center -> (elementWidth / 2) - (textSize.width / 2) | |
HorizontalStackedBarNameAlignment.End -> elementWidth - textSize.width | |
} | |
drawText( | |
textMeasurer = textMeasurer, | |
text = element.barName.text, | |
style = nameTextStyle.copy(color = element.barName.textColor), | |
topLeft = Offset( | |
stackedWidth + alignmentOffset, | |
nameYPos | |
), | |
size = Size(textSize.width.toFloat(), textSize.height.toFloat()) | |
) | |
} | |
@Composable | |
fun HorizontalStackedBarIcon( | |
modifier: Modifier = Modifier, | |
state: HorizontalStackedBarState, | |
vectorPainterState: VectorPainterState, | |
elementSpaceCount: (List<HorizontalStackedBarElement>) -> Int, | |
) { | |
Canvas(modifier = modifier) { | |
val targetValue = min(state.normalizedValue, state.normalizedBase) | |
val targetElement = state.normalizedElements.firstOrNull { | |
state.normalizedValue in it.min..it.max | |
} ?: return@Canvas | |
val elements = state.normalizedElements | |
val elementTotalWidth = size.width - (state.elementSpace * elementSpaceCount(elements)) | |
val spaceCount = elementSpaceCount(elements.subList(0, elements.indexOf(targetElement) + 1)) | |
val xPos = | |
(targetValue * elementTotalWidth / state.normalizedBase) + (spaceCount * state.elementSpace) | |
drawVectorIcon( | |
color = targetElement.color, | |
start = xPos, | |
vectorPainterState = vectorPainterState, | |
barHeight = state.barHeight, | |
) | |
} | |
} | |
fun DrawScope.drawVectorIcon( | |
color: Color, | |
start: Float, | |
vectorPainterState: VectorPainterState, | |
barHeight: Float, | |
) { | |
val painter = vectorPainterState.vectorPainter | |
val position = vectorPainterState.vectorPainterPosition | |
val backgroundColor = vectorPainterState.vectorPainterBackgroundColor | |
val iconHorizontalPadding = vectorPainterState.vectorPainterHorizontalPadding | |
val iconWidth = painter.intrinsicSize.width | |
val iconHeight = painter.intrinsicSize.height | |
val xPos = if (start + iconWidth > size.width) { | |
size.width - iconWidth | |
} else if (start < (iconWidth / 2)) { | |
0f | |
} else start - (iconWidth / 2) | |
val yPos = when (position) { | |
is VectorPainterPosition.Top -> (size.height / 2) - (barHeight / 2) - position.bottomSpace - iconHeight | |
is VectorPainterPosition.Bottom -> (size.height / 2) + (barHeight / 2) + position.topSpace | |
VectorPainterPosition.Center -> (size.height / 2) - (iconHeight / 2) | |
} | |
val topLeft = Offset(xPos, yPos) | |
drawRect( | |
color = backgroundColor, | |
topLeft = topLeft.copy(x = topLeft.x - iconHorizontalPadding), | |
size = Size( | |
width = iconWidth + (iconHorizontalPadding * 2), | |
height = iconHeight | |
) | |
) | |
translate(left = topLeft.x, top = topLeft.y) { | |
with(painter) { | |
draw( | |
size = painter.intrinsicSize, | |
colorFilter = ColorFilter.tint(color = color) | |
) | |
} | |
} | |
} | |
/** | |
* @property chartHeight 바차트 기준 상하 패딩 포함한 전체 높이 | |
* @property barHeight bar 의 height | |
* @property elementSpace 차트의 각 요소 사이의 간격 | |
* @property elements 차트의 각 요소 목록, | |
* @property elementNameTextStyle 차트의 각 요소별 이름, | |
* @property elementNameTopPadding 차트의 각 요소별 이름과 차트 사이의 거리 | |
* @property vectorPainterState 차트에 그려질 아이콘 상태 | |
* @property value 해당 값, 아이콘 표출 위치 | |
*/ | |
data class HorizontalStackedBarState( | |
val chartHeight: Float, | |
val barHeight: Float, | |
val elementSpace: Float, | |
val elements: List<HorizontalStackedBarElement>, | |
val elementNameTextStyle: TextStyle, | |
val elementNameTopPadding: Float, | |
val vectorPainterState: VectorPainterState?, | |
val value: Float, | |
) { | |
private val start = elements.minOf { it.min } | |
val normalizedElements: List<HorizontalStackedBarElement> = elements.map { | |
it.copy(min = it.min - start, max = it.max - start, base = it.base - start) | |
} | |
val normalizedBase = normalizedElements.firstOrNull()?.base ?: 0f | |
val normalizedValue = value - start | |
fun getTotalHeight(textMeasurer: TextMeasurer): Float { | |
val name = firstElementName() | |
val nameHeight = if (name.isNullOrBlank()) 0f else { | |
textMeasurer.measure(name, style = elementNameTextStyle).size.height.toFloat() | |
} | |
return chartHeight + nameHeight | |
} | |
private fun firstElementName() = elements.firstOrNull { it.barName != null }?.barName?.text | |
} | |
data class HorizontalStackedBarElement( | |
val base: Float, | |
val min: Float, | |
val max: Float, | |
val color: Color, | |
val useSpace: Boolean = true, | |
val barName: HorizontalStackedBarName? = null, | |
) { | |
fun width(totalWidth: Float) = max(0.1f, ((max - min) * totalWidth) / base) | |
} | |
data class HorizontalStackedBarName( | |
val text: String, | |
val textColor: Color, | |
val alignment: HorizontalStackedBarNameAlignment, | |
) | |
enum class HorizontalStackedBarNameAlignment { | |
Start, | |
Center, | |
End; | |
} | |
/** | |
* @property vectorPainter chart icon | |
* @property vectorPainterHorizontalPadding icon background space | |
* @property vectorPainterPosition | |
* @property vectorPainterBackgroundColor chart background color | |
*/ | |
data class VectorPainterState( | |
val vectorPainter: VectorPainter, | |
val vectorPainterHorizontalPadding: Float, | |
val vectorPainterPosition: VectorPainterPosition, | |
val vectorPainterBackgroundColor: Color, | |
) | |
sealed class VectorPainterPosition { | |
data class Top(val bottomSpace: Float) : VectorPainterPosition() | |
data class Bottom(val topSpace: Float) : VectorPainterPosition() | |
data object Center : VectorPainterPosition() | |
} | |
@Composable | |
fun rememberVectorPainterState( | |
vectorIcon: ImageVector? = null, | |
vectorPainterHorizontalPadding: Float = 1.dp.toPx(), | |
vectorPainterPosition: VectorPainterPosition = VectorPainterPosition.Center, | |
vectorPainterBackgroundColor: Color = VCTheme.colors.surface99, | |
): VectorPainterState? { | |
vectorIcon ?: return null | |
val painter = rememberVectorPainter(image = vectorIcon) | |
return remember(vectorIcon) { | |
VectorPainterState( | |
vectorPainter = painter, | |
vectorPainterHorizontalPadding = vectorPainterHorizontalPadding, | |
vectorPainterPosition = vectorPainterPosition, | |
vectorPainterBackgroundColor = vectorPainterBackgroundColor | |
) | |
} | |
} | |
@Composable | |
fun rememberHorizontalStackedBarState( | |
value: Float, | |
elements: List<HorizontalStackedBarElement>, | |
vectorPainterState: VectorPainterState? = null, | |
chartHeight: Float = 16.dp.toPx(), | |
barHeight: Float = 3.dp.toPx(), | |
elementSpace: Float = 1.dp.toPx(), | |
elementNameTextStyle: TextStyle = VCTheme.typo.caption1, | |
elementNameTopPadding: Float = 0.dp.toPx(), | |
): HorizontalStackedBarState { | |
return remember { | |
HorizontalStackedBarState( | |
value = value, | |
elements = elements, | |
chartHeight = chartHeight, | |
barHeight = barHeight, | |
elementSpace = elementSpace, | |
elementNameTextStyle = elementNameTextStyle, | |
elementNameTopPadding = elementNameTopPadding, | |
vectorPainterState = vectorPainterState, | |
) | |
} | |
} | |
@Composable | |
private fun elementsForPreview() = rememberHorizontalStackedBarState( | |
value = 90f, | |
elements = listOf( | |
HorizontalStackedBarElement( | |
base = 100f, | |
min = 0f, | |
max = 30f, | |
color = VCTheme.colors.graphLow, | |
barName = HorizontalStackedBarName( | |
"Low", | |
VCTheme.colors.graphLow, | |
HorizontalStackedBarNameAlignment.Start | |
), | |
useSpace = false, | |
), | |
HorizontalStackedBarElement( | |
base = 100f, | |
min = 31f, | |
max = 70f, | |
color = VCTheme.colors.graphSafety, | |
barName = HorizontalStackedBarName( | |
"Safety", | |
VCTheme.colors.graphSafety, | |
HorizontalStackedBarNameAlignment.Center | |
), | |
useSpace = true, | |
), | |
HorizontalStackedBarElement( | |
base = 100f, | |
min = 71f, | |
max = 100f, | |
color = VCTheme.colors.graphHigh, | |
barName = HorizontalStackedBarName( | |
"High", | |
VCTheme.colors.graphHigh, | |
HorizontalStackedBarNameAlignment.End | |
), | |
useSpace = false, | |
) | |
), | |
vectorPainterState = rememberVectorPainterState( | |
vectorIcon = ImageVector.vectorResource(id = R.drawable.ic_16_good), | |
vectorPainterBackgroundColor = Color.Transparent | |
), | |
chartHeight = 80f, | |
barHeight = 16f, | |
elementSpace = 1f, | |
elementNameTextStyle = VCTheme.typo.caption1, | |
elementNameTopPadding = 4f | |
) | |
@Preview | |
@Composable | |
fun HorizontalStackedBarPreview() { | |
VirtualCareTheme { | |
val elements = elementsForPreview().elements | |
HorizontalStackedBar( | |
state = elementsForPreview(), | |
elementSpaceCount = { elements.count { it.useSpace } } | |
) | |
} | |
} | |
@Preview | |
@Composable | |
fun HorizontalStackedBarNamePreview() { | |
VirtualCareTheme(darkTheme = true) { | |
val elements = elementsForPreview().elements | |
HorizontalStackedBarName( | |
textMeasurer = rememberTextMeasurer(), | |
state = elementsForPreview(), | |
modifier = Modifier | |
.fillMaxWidth() | |
.heightIn(min = 20.dp) | |
) { | |
elements.count { it.useSpace } | |
} | |
} | |
} | |
/** | |
* @see Legend | |
* @see ReportCard | |
* */ | |
@Preview | |
@Composable | |
fun HorizontalStackedBarChartPreview() { | |
VirtualCareTheme { | |
Column { | |
HorizontalStackedBarChart( | |
textMeasurer = rememberTextMeasurer(), | |
state = elementsForPreview(), | |
) | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@OptIn(ExperimentalLayoutApi::class) | |
@Composable | |
fun Legend(modifier: Modifier = Modifier, legendState: LegendState) { | |
when (legendState.direction) { | |
is LegendDirection.Horizontal -> { | |
FlowRow( | |
modifier = modifier | |
.fillMaxWidth() | |
.wrapContentHeight(), | |
horizontalArrangement = legendState.direction.arrangement | |
) { | |
legendState.legends.forEach { legend -> | |
Row(verticalAlignment = Alignment.CenterVertically) { | |
LegendShape(legend.shape) | |
Spacer(modifier = Modifier.width(legend.spaceToShape)) | |
legend.content(this) | |
} | |
} | |
} | |
} | |
is LegendDirection.Vertical -> { | |
Column( | |
modifier = modifier | |
.fillMaxWidth() | |
.wrapContentHeight(), | |
verticalArrangement = Arrangement.spacedBy(legendState.direction.space), | |
) { | |
legendState.legends.forEach { legend -> | |
Row( | |
modifier = Modifier | |
.fillMaxWidth() | |
.wrapContentHeight(), | |
verticalAlignment = Alignment.CenterVertically, | |
) { | |
LegendShape(legend.shape) | |
Spacer(modifier = Modifier.width(legend.spaceToShape)) | |
legend.content(this) | |
} | |
} | |
} | |
} | |
} | |
} | |
@Composable | |
fun LegendShape(shape: LegendShapeState) { | |
when (shape.type) { | |
DASH -> { | |
// TODO() | |
} | |
RECT -> { | |
// TODO() | |
} | |
OUTLINED_RECT -> { | |
// TODO() | |
} | |
CIRCLE -> { | |
Canvas(modifier = Modifier.size(shape.size)) { | |
val halfWidth = size.width / 2 | |
drawCircle( | |
color = shape.color, | |
radius = halfWidth, | |
center = Offset(halfWidth, halfWidth) | |
) | |
} | |
} | |
} | |
} | |
@Composable | |
fun rememberLegendState( | |
modifier: Modifier = Modifier, | |
legends: List<LegendElement>, | |
direction: LegendDirection = LegendDirection.Horizontal(), | |
) = remember(legends) { | |
LegendState(modifier = modifier, legends = legends, direction = direction) | |
} | |
/** | |
* @property[legends] [LegendElement] 목록 | |
* @property[direction] [LegendDirection] | |
*/ | |
data class LegendState( | |
val modifier: Modifier, | |
val legends: List<LegendElement>, | |
val direction: LegendDirection, | |
) | |
sealed class LegendDirection { | |
data class Horizontal( | |
val arrangement: Arrangement.Horizontal = Arrangement.spacedBy( | |
space = 16.dp, | |
alignment = Alignment.Start | |
) | |
) : LegendDirection() | |
data class Vertical(val space: Dp = 8.dp) : LegendDirection() | |
} | |
/** | |
* @property[content] 범례 | |
* @property[shape] 범례 모양 정보 | |
* @property[spaceToShape] 범례 모양과 설명의 간격 | |
*/ | |
data class LegendElement( | |
val content: @Composable RowScope.() -> Unit, | |
val shape: LegendShapeState, | |
val spaceToShape: Dp = 4.dp, | |
) | |
/** | |
* @property[type] 범례 모양 | |
* @property[size] 범례 모양 사이즈 | |
* @property[color] 범례 모양 color | |
*/ | |
data class LegendShapeState( | |
val type: LegendShapeType, | |
val size: DpSize, | |
val color: Color, | |
) | |
/** | |
* [DASH] | |
* [RECT] | |
* [OUTLINED_RECT] | |
* [CIRCLE] | |
*/ | |
enum class LegendShapeType { | |
DASH, | |
RECT, | |
OUTLINED_RECT, | |
CIRCLE; | |
} | |
@Preview(widthDp = 360) | |
@Composable | |
fun Legend_Horizontal_Preview() { | |
VirtualCareTheme(darkTheme = true) { | |
Legend( | |
legendState = rememberLegendState( | |
legends = listOf( | |
LegendElement( | |
content = @Composable { | |
Text( | |
text = "범례1", | |
style = VCTheme.typo.caption1.copy(color = VCTheme.colors.onSurface40) | |
) | |
}, | |
shape = LegendShapeState( | |
type = CIRCLE, | |
size = DpSize(4.dp, 4.dp), | |
color = VCTheme.colors.graphHigh | |
) | |
), LegendElement( | |
content = @Composable { | |
Text( | |
text = "범례2", | |
style = VCTheme.typo.caption1.copy(color = VCTheme.colors.onSurface40) | |
) | |
}, | |
shape = LegendShapeState( | |
type = CIRCLE, | |
size = DpSize(4.dp, 4.dp), | |
color = VCTheme.colors.graphHigh40 | |
) | |
), LegendElement( | |
content = @Composable { | |
Text( | |
text = "범례3", | |
style = VCTheme.typo.caption1.copy(color = VCTheme.colors.onSurface40) | |
) | |
}, | |
shape = LegendShapeState( | |
type = CIRCLE, | |
size = DpSize(4.dp, 4.dp), | |
color = VCTheme.colors.graphHigh80 | |
) | |
) | |
), | |
direction = LegendDirection.Horizontal() | |
) | |
) | |
} | |
} | |
@Preview(widthDp = 360) | |
@Composable | |
fun Legend_Vertical_Preview() { | |
VirtualCareTheme(darkTheme = true) { | |
Legend( | |
legendState = rememberLegendState( | |
legends = listOf( | |
LegendElement( | |
content = @Composable { | |
Text( | |
modifier = Modifier.weight(1f), | |
text = "범례1", | |
style = VCTheme.typo.caption1.copy(color = VCTheme.colors.onSurface40) | |
) | |
}, | |
shape = LegendShapeState( | |
type = CIRCLE, | |
size = DpSize(4.dp, 4.dp), | |
color = VCTheme.colors.graphHigh | |
) | |
), LegendElement( | |
content = @Composable { | |
Text( | |
modifier = Modifier.weight(1f), | |
text = "범례2", | |
style = VCTheme.typo.caption1.copy(color = VCTheme.colors.onSurface40) | |
) | |
}, | |
shape = LegendShapeState( | |
type = CIRCLE, | |
size = DpSize(4.dp, 4.dp), | |
color = VCTheme.colors.graphHigh40 | |
) | |
), LegendElement( | |
content = @Composable { | |
Text( | |
modifier = Modifier.weight(1f), | |
text = "범례3", | |
style = VCTheme.typo.caption1.copy(color = VCTheme.colors.onSurface40) | |
) | |
}, | |
shape = LegendShapeState( | |
type = CIRCLE, | |
size = DpSize(4.dp, 4.dp), | |
color = VCTheme.colors.graphHigh80 | |
) | |
) | |
), | |
direction = LegendDirection.Vertical() | |
) | |
) | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
fun Float?.toIntegerableString( | |
nullStr: String = "", | |
requireRound: Boolean = false, | |
requireTwoDecimalPlaces: Boolean = false | |
): String = if (this == null) { | |
nullStr | |
} else if (requireRound || (this % 1).compareTo(0f) == 0) { | |
DecimalFormat("##,###").format(roundHalfUp(0).toInt()) | |
} else if (requireTwoDecimalPlaces && (this % 0.1).compareTo(0f) != 0) { | |
DecimalFormat("##,###.##").format(this) | |
} else { | |
DecimalFormat("##,###.#").format(this) | |
} | |
fun Dp.toPx(): Float { | |
return this.value * getDensity() | |
} | |
fun Float.toDp(): Dp { | |
return (this / getDensity()).dp | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment