@symbiot.dev/react-native-heatmap
Version:
A lightweight and customizable activity graph for React Native, perfect for visualizing user engagement, habits, streaks, or productivity over time.
2 lines (1 loc) • 18.1 kB
JavaScript
import{jsxs,jsx}from"react/jsx-runtime";import{Platform,Appearance,View,StyleSheet,Text,ScrollView,Pressable}from"react-native";import{useState,useEffect,memo,useRef,useCallback,Fragment,useMemo}from"react";import Svg,{Rect,Text as Text$1}from"react-native-svg";import{format}from"date-fns/format";import"date-fns/isAfter";import"date-fns/isBefore";import{addDays}from"date-fns/addDays";import"date-fns/getDay";import"date-fns/startOfWeek";import"date-fns/differenceInDays";import"date-fns/toDate";import{isSameDay}from"date-fns/isSameDay";import"date-fns/startOfDay";import{startOfYear}from"date-fns/startOfYear";import{eachDayOfInterval}from"date-fns/eachDayOfInterval";import{differenceInCalendarMonths}from"date-fns/differenceInCalendarMonths";import{eachMonthOfInterval}from"date-fns/eachMonthOfInterval";import{differenceInYears}from"date-fns/differenceInYears";import{differenceInCalendarWeeks}from"date-fns/differenceInCalendarWeeks";const isWeb="web"===Platform.OS;Platform.OS,Platform.OS;var DirectionOffset;!function(DirectionOffset){DirectionOffset[DirectionOffset.PREV=-1]="PREV",DirectionOffset[DirectionOffset.NEXT=1]="NEXT"}(DirectionOffset||(DirectionOffset={}));function isObject(value){return"object"==typeof value&&null!==value}function objectsEqual(a,b){if(a===b)return!0;if(a instanceof Date&&b instanceof Date)return a.getTime()===b.getTime();if(!isObject(a)||!isObject(b))return!1;const keysA=Object.keys(a),keysB=Object.keys(b);if(keysA.length!==keysB.length)return!1;for(const key of keysA){if(!Object.prototype.hasOwnProperty.call(b,key))return!1;if(!objectsEqual(a[key],b[key]))return!1}return!0}let isSlow;new Promise((resolve=>{const start=performance.now();Array.from({length:100},((_,i)=>i)).sort((()=>Math.random()-.5)),resolve(+(performance.now()-start).toFixed(2)>.1)})).then((value=>isSlow=value));const HorizontalHeatMap=memo((({rtl:rtl,pressable:pressable,hoverable:hoverable,scrollEnabled:scrollEnabled,showsHorizontalScrollIndicator:showsHorizontalScrollIndicator,width:width,height:height,cells:cells,isCellTextVisible:isCellTextVisible,cellGap:cellGap,cellDefaultColor:cellDefaultColor,cellSize:cellSize,cellRadius:cellRadius,cellTextColor:cellTextColor,cellTextFontSize:cellTextFontSize,isHeaderVisible:isHeaderVisible,headerLabels:headerLabels,locale:locale,headerTextAlign:headerTextAlign,headerTextFormat:headerTextFormat,headerTextFontSize:headerTextFontSize,headerBottomSpace:headerBottomSpace,headerTextColor:headerTextColor,getCellColor:getCellColor,scrollStyle:scrollStyle,isSidebarVisible:isSidebarVisible,sidebarLabels:sidebarLabels,sidebarTextColor:sidebarTextColor,sideBarTextFontSize:sideBarTextFontSize,onCellPress:onCellPress,onMouseEnter:onMouseEnter,onMouseLeave:onMouseLeave})=>{const{rendered:rendered}=(props=>{const[rendered,setRendered]=useState(!!(null==props?void 0:props.defaultTrue));return useEffect((()=>{setTimeout((()=>setRendered(!0)),null==props?void 0:props.delay)}),[]),{rendered:rendered}})({delay:100,defaultTrue:!isSlow||!isCellTextVisible}),scrollViewRef=useRef(null),scrolledRef=useRef(!1),hoveredCellRef=useRef(void 0),adjustedHeaderLabels=rtl?headerLabels.slice().reverse():headerLabels,adjustedCells=rtl?cells.slice().reverse().map((cell=>Object.assign(Object.assign({},cell),{x:width-cellSize-cellGap-cell.x}))):cells,onLayout=useCallback((()=>{var _a;!scrolledRef.current&&rtl&&(scrolledRef.current=!0,null===(_a=scrollViewRef.current)||void 0===_a||_a.scrollToEnd({animated:!1}))}),[]),getCellFromCoords=useCallback((({x:x,y:y})=>{let targetCell;return adjustedCells.forEach(rtl?cell=>{x>=cell.x&&x<=cell.x+cellSize&&cell.y+cellSize>=y&&cell.y<=y&&(targetCell=cell)}:cell=>{x>=cell.x&&x<=cell.x+cellSize&&y>=cell.y&&y<=cell.y+cellSize&&(targetCell=cell)}),targetCell}),[adjustedCells,cellSize,rtl]),onPress=useCallback((e=>{if(!pressable)return;const nativeEvent=e.nativeEvent,cell=getCellFromCoords({x:isWeb?nativeEvent.offsetX:nativeEvent.locationX,y:isWeb?nativeEvent.offsetY:nativeEvent.locationY});cell&&(null==onCellPress||onCellPress({date:cell.date,count:cell.count}))}),[getCellFromCoords,onCellPress,pressable]),onPointerMove=useCallback((e=>{if(!hoverable)return;const{offsetX:offsetX,offsetY:offsetY}=e.nativeEvent,cell=getCellFromCoords({x:offsetX,y:offsetY});objectsEqual(hoveredCellRef.current,cell)||(hoveredCellRef.current=cell,cell?null==onMouseEnter||onMouseEnter({date:cell.date,x:cell.x,y:cell.y,count:cell.count}):null==onMouseLeave||onMouseLeave())}),[getCellFromCoords,hoverable,onMouseEnter,onMouseLeave]),onPointerLeave=useCallback((()=>{hoveredCellRef.current&&hoverable&&(hoveredCellRef.current=void 0,null==onMouseLeave||onMouseLeave())}),[hoverable,onMouseLeave]);return jsxs(View,{style:[styles.container,{flexDirection:rtl?"row-reverse":"row"}],children:[isSidebarVisible&&jsx(View,{style:{marginTop:isHeaderVisible?headerTextFontSize+headerBottomSpace:0,gap:cellGap},children:sidebarLabels.map((({label:label},i)=>jsx(Text,{style:{color:sidebarTextColor,lineHeight:cellSize,height:cellSize,paddingHorizontal:8,fontSize:sideBarTextFontSize},children:label},i)))}),jsxs(ScrollView,{horizontal:!0,ref:scrollViewRef,scrollEnabled:scrollEnabled,showsHorizontalScrollIndicator:showsHorizontalScrollIndicator,style:{maxWidth:width},contentContainerStyle:[styles.content,{paddingLeft:isSidebarVisible?0:8},scrollStyle],onLayout:onLayout,children:[isHeaderVisible&&jsx(View,{style:[styles.header,{marginBottom:headerBottomSpace}],children:adjustedHeaderLabels.map((({date:date,width:width},index)=>jsx(Text,{style:{width:width-(index===(rtl?0:headerLabels.length-1)?cellGap:0),fontSize:headerTextFontSize,lineHeight:headerTextFontSize,color:headerTextColor,textAlign:headerTextAlign||(rtl?"right":"left")},children:format(date,headerTextFormat,{locale:locale})},date.toString())))}),jsx(Pressable,{disabled:!pressable,style:styles.pressable,onPress:onPress,onPointerMove:onPointerMove,onPointerLeave:onPointerLeave,children:jsx(Svg,{width:width-cellGap,height:height-cellGap,children:adjustedCells.map((({label:label,count:count,x:x,y:y},index)=>jsxs(Fragment,{children:[jsx(Rect,{x:x,y:y,width:cellSize,height:cellSize,rx:cellRadius,ry:cellRadius,fill:getCellColor(count)||cellDefaultColor,style:{cursor:pressable?"pointer":void 0}}),isCellTextVisible&&rendered&&!!label&&jsx(Text$1,{fontSize:cellTextFontSize,textAnchor:"middle",alignmentBaseline:"middle",x:x+cellSize/2,y:y+(cellSize+cellGap/2)/2,fill:cellTextColor,style:{userSelect:"none",pointerEvents:"none"},children:label},`txt_${index}`)]},index)))})})]})]})})),styles=StyleSheet.create({container:{maxWidth:"100%",justifyContent:"center"},header:{flexDirection:"row"},content:{flexDirection:"column"},pressable:{cursor:"auto"}}),SchemeColors={light:{headerTextColor:"#000000",cellDefaultColor:"#F1F1F1",cellDefaultHighlightedColor:"#C1C1C1",cellTextColor:"#A1A1A1",sidebarTextColor:"#000000"},dark:{headerTextColor:"#FFFFFF",cellDefaultColor:"#303030",cellDefaultHighlightedColor:"#505050",cellTextColor:"#909090",sidebarTextColor:"#FFFFFF"}},useTheme=theme=>{const systemScheme=(()=>{const[systemScheme,setSystemScheme]=useState(Appearance.getColorScheme());return useEffect((()=>{const listener=Appearance.addChangeListener((({colorScheme:colorScheme})=>setSystemScheme(colorScheme)));return()=>listener.remove()}),[]),systemScheme})(),colors=useMemo((()=>{var _a,_b,_c,_d,_e;const scheme=(null==theme?void 0:theme.scheme)||systemScheme||"light",colors=SchemeColors[scheme],cellColor=(null===(_a=null==theme?void 0:theme[scheme])||void 0===_a?void 0:_a.cellColor)||(null==theme?void 0:theme.cellColor);return{headerTextColor:(null===(_b=null==theme?void 0:theme[scheme])||void 0===_b?void 0:_b.headerTextColor)||(null==theme?void 0:theme.headerTextColor)||colors.headerTextColor,cellDefaultColor:(null===(_c=null==theme?void 0:theme[scheme])||void 0===_c?void 0:_c.cellDefaultColor)||(null==theme?void 0:theme.cellDefaultColor)||colors.cellDefaultColor,cellTextColor:(null===(_d=null==theme?void 0:theme[scheme])||void 0===_d?void 0:_d.cellTextColor)||(null==theme?void 0:theme.cellTextColor)||colors.cellTextColor,sidebarTextColor:(null===(_e=null==theme?void 0:theme[scheme])||void 0===_e?void 0:_e.sidebarTextColor)||(null==theme?void 0:theme.sidebarTextColor)||colors.sidebarTextColor,cellColor:cellColor||{1:colors.cellDefaultHighlightedColor},sortedColorResolverKeys:cellColor?Object.keys(cellColor).map(Number).sort():[1]}}),[systemScheme,theme]),getCellColor=useCallback((count=>{var _a;const colorKey=Math.max(...colors.sortedColorResolverKeys.filter((value=>count>=value)));return null===(_a=colors.cellColor)||void 0===_a?void 0:_a[colorKey]}),[colors.cellColor,colors.sortedColorResolverKeys]);return Object.assign(Object.assign({},colors),{getCellColor:getCellColor})},useDatetime=props=>useMemo((()=>{const start=props.startDate||startOfYear(new Date),end=props.endDate||addDays(start,props.baseDuration),hiddenDays=(hiddenDays=>hiddenDays?[...new Set(hiddenDays.filter((day=>day<7)))].sort().slice(0,6):[])(props.hiddenDays),visibleDays=((weekStartsOn,hiddenDays)=>{const weekDays=[];for(let i=0;i<7;i++){const day=(weekStartsOn+i)%7;hiddenDays.includes(day)||weekDays.push(day)}return weekDays})(props.weekStartsOn,hiddenDays);return{start:start,end:end,hiddenDays:hiddenDays,visibleDays:visibleDays}}),[props.baseDuration,props.endDate,props.hiddenDays,props.startDate,props.weekStartsOn]),useController=props=>useMemo((()=>{var _a,_b;const scrollEnabled=null===(_a=props.scrollable)||void 0===_a||_a;return{scrollEnabled:scrollEnabled,showsHorizontalScrollIndicator:scrollEnabled&&isWeb,pressable:!!props.pressable,hoverable:!!props.hoverable,rtl:!!props.rtl,isHeaderVisible:null===(_b=props.isHeaderVisible)||void 0===_b||_b,isCellTextVisible:!!props.isCellTextVisible,isSidebarVisible:!!props.isSidebarVisible}}),[props.isCellTextVisible,props.isHeaderVisible,props.isSidebarVisible,props.pressable,props.hoverable,props.rtl,props.scrollable]),useDimensions=props=>useMemo((()=>{var _a;const headerTextFontSize=props.headerTextFontSize||12,headerBottomSpace=props.headerBottomSpace||4,headerHeight=props.isHeaderVisible?headerTextFontSize+headerBottomSpace:0,cellSize=props.cellSize||20,cellRadius=props.cellRadius||2,cellGap=props.cellGap||2;return{headerHeight:headerHeight,headerBottomSpace:headerBottomSpace,headerTextFontSize:headerTextFontSize,cellSize:cellSize,cellRadius:cellRadius,cellGap:cellGap,cellTextFontSize:props.cellTextFontSize||Math.max(Math.min(cellSize/3,14),8),adjustedCellSize:cellSize+cellGap,sideBarTextFontSize:null!==(_a=props.sideBarTextFontSize)&&void 0!==_a?_a:12}}),[props.headerTextFontSize,props.headerBottomSpace,props.isHeaderVisible,props.cellSize,props.cellRadius,props.cellGap,props.cellTextFontSize,props.sideBarTextFontSize]),getCountFromDailyData=(date,data)=>{let count=0;if(data)if(Array.isArray(data))count=data.filter((dataItem=>isSameDay(date,dataItem))).length;else{const key=Object.keys(data).find((dateKey=>isSameDay(dateKey,date)));key&&(count=data[key])}return count},MonthlyHeatMap=props=>{const{start:start,end:end,hiddenDays:hiddenDays}=useDatetime(Object.assign(Object.assign({},props),{baseDuration:364,weekStartsOn:0})),{headerTextColor:headerTextColor,cellDefaultColor:cellDefaultColor,cellTextColor:cellTextColor,getCellColor:getCellColor,sidebarTextColor:sidebarTextColor}=useTheme(props.theme),{rtl:rtl,scrollEnabled:scrollEnabled,showsHorizontalScrollIndicator:showsHorizontalScrollIndicator,pressable:pressable,hoverable:hoverable,isHeaderVisible:isHeaderVisible,isCellTextVisible:isCellTextVisible,isSidebarVisible:isSidebarVisible}=useController(props),{cellSize:cellSize,cellGap:cellGap,cellRadius:cellRadius,cellTextFontSize:cellTextFontSize,adjustedCellSize:adjustedCellSize,headerBottomSpace:headerBottomSpace,headerTextFontSize:headerTextFontSize,sideBarTextFontSize:sideBarTextFontSize}=useDimensions(Object.assign(Object.assign({},props),{isHeaderVisible:isHeaderVisible})),sidebarLabels=useMemo((()=>{if(!props.isSidebarVisible)return[];const sidebarTextFormat=props.sidebarTextFormat||"dd";return eachDayOfInterval({start:new Date(2025,0,1),end:new Date(2025,0,31)}).map((date=>({value:date.toString(),label:format(date,sidebarTextFormat,{locale:props.locale})})))}),[props.isSidebarVisible,props.locale,props.sidebarTextFormat]),{width:width,height:height,cells:cells,headerLabels:headerLabels,headerTextFormat:headerTextFormat}=useMemo((()=>{const countMonths=differenceInCalendarMonths(end,start)+1,labels=eachMonthOfInterval({start:start,end:end}).map((date=>({date:date,x:differenceInCalendarMonths(date,start)*adjustedCellSize}))),dates=eachDayOfInterval({start:start,end:end}),adjustedDates=hiddenDays.length?dates.filter((date=>!hiddenDays.includes(date.getDay()))):dates,width=adjustedCellSize*countMonths;return{width:width,height:31*adjustedCellSize,cells:adjustedDates.map((date=>{const count=getCountFromDailyData(date,props.data);return{date:date,count:count,label:count,x:differenceInCalendarMonths(date,start)*adjustedCellSize,y:(date.getDate()-1)*adjustedCellSize}})),headerTextFormat:props.headerTextFormat||(differenceInYears(end,start)?"MMMMM yy":"MMMMM"),headerLabels:labels.map(((label,index)=>Object.assign(Object.assign({},label),{width:(index===labels.length-1?width:labels[index+1].x)-label.x})))}}),[start,adjustedCellSize,end,hiddenDays,props.data,props.headerTextFormat]);return jsx(HorizontalHeatMap,{rtl:rtl,pressable:pressable,hoverable:hoverable,scrollEnabled:scrollEnabled,showsHorizontalScrollIndicator:showsHorizontalScrollIndicator,width:width,height:height,cells:cells,isCellTextVisible:isCellTextVisible,cellGap:cellGap,cellDefaultColor:cellDefaultColor,cellSize:cellSize,cellRadius:cellRadius,cellTextColor:cellTextColor,cellTextFontSize:cellTextFontSize,isHeaderVisible:isHeaderVisible,headerBottomSpace:headerBottomSpace,headerTextFontSize:headerTextFontSize,headerTextColor:headerTextColor,headerLabels:headerLabels,headerTextFormat:headerTextFormat,headerTextAlign:props.headerTextAlign,locale:props.locale,scrollStyle:props.scrollStyle,isSidebarVisible:isSidebarVisible,sidebarLabels:sidebarLabels,sidebarTextColor:sidebarTextColor,sideBarTextFontSize:sideBarTextFontSize,getCellColor:getCellColor,onCellPress:props.onCellPress,onMouseEnter:props.onMouseEnter,onMouseLeave:props.onMouseLeave})},WeeklyHeatMap=props=>{const weekStartsOn=useMemo((()=>((weekStartsOn=0)=>Math.min(6,Math.round(Math.abs(weekStartsOn))))(props.weekStartsOn)),[props.weekStartsOn]),{start:start,end:end,hiddenDays:hiddenDays,visibleDays:visibleDays}=useDatetime(Object.assign(Object.assign({},props),{baseDuration:364,weekStartsOn:weekStartsOn})),{headerTextColor:headerTextColor,cellDefaultColor:cellDefaultColor,cellTextColor:cellTextColor,getCellColor:getCellColor,sidebarTextColor:sidebarTextColor}=useTheme(props.theme),{rtl:rtl,scrollEnabled:scrollEnabled,showsHorizontalScrollIndicator:showsHorizontalScrollIndicator,pressable:pressable,hoverable:hoverable,isHeaderVisible:isHeaderVisible,isCellTextVisible:isCellTextVisible,isSidebarVisible:isSidebarVisible}=useController(props),{cellSize:cellSize,cellGap:cellGap,cellRadius:cellRadius,cellTextFontSize:cellTextFontSize,adjustedCellSize:adjustedCellSize,headerBottomSpace:headerBottomSpace,headerTextFontSize:headerTextFontSize,sideBarTextFontSize:sideBarTextFontSize}=useDimensions(Object.assign(Object.assign({},props),{isHeaderVisible:isHeaderVisible})),sidebarLabels=useMemo((()=>{if(!props.isSidebarVisible)return[];const baseSunday=new Date(2023,0,1),sidebarTextFormat=props.sidebarTextFormat||"eeeeee";return visibleDays.map((value=>({value:value,label:format(addDays(baseSunday,value),sidebarTextFormat,{locale:props.locale})})))}),[props.isSidebarVisible,props.locale,props.sidebarTextFormat,visibleDays]),{width:width,height:height,cells:cells,headerLabels:headerLabels,headerTextFormat:headerTextFormat}=useMemo((()=>{var _a;const cellText=null!==(_a=props.cellText)&&void 0!==_a?_a:"date",countWeeks=differenceInCalendarWeeks(end,start,{weekStartsOn:weekStartsOn}),labels=eachMonthOfInterval({start:start,end:end}).map((date=>({date:date,x:differenceInCalendarWeeks(date,start,{weekStartsOn:weekStartsOn})*adjustedCellSize}))),dates=eachDayOfInterval({start:start,end:end}),adjustedDates=hiddenDays.length?dates.filter((date=>!hiddenDays.includes(date.getDay()))):dates,width=adjustedCellSize*(countWeeks+1),height=adjustedCellSize*visibleDays.length;return{width:width,height:height,cells:adjustedDates.map((date=>{const day=date.getDay(),count=getCountFromDailyData(date,props.data);return{date:date,count:count,label:"date"===cellText?date.getDate():count,x:differenceInCalendarWeeks(date,start,{weekStartsOn:weekStartsOn})*adjustedCellSize,y:(hiddenDays.length?visibleDays.indexOf(day):day)*adjustedCellSize}})),headerTextFormat:props.headerTextFormat||(differenceInYears(end,start)?"MMM yy":"MMM"),headerLabels:labels.map(((label,index)=>Object.assign(Object.assign({},label),{width:(index===labels.length-1?width:labels[index+1].x)-label.x})))}}),[props.cellText,props.data,props.headerTextFormat,end,start,weekStartsOn,adjustedCellSize,visibleDays,hiddenDays]);return jsx(HorizontalHeatMap,{rtl:rtl,pressable:pressable,hoverable:hoverable,scrollEnabled:scrollEnabled,showsHorizontalScrollIndicator:showsHorizontalScrollIndicator,width:width,height:height,cells:cells,isCellTextVisible:isCellTextVisible,cellGap:cellGap,cellDefaultColor:cellDefaultColor,cellSize:cellSize,cellRadius:cellRadius,cellTextColor:cellTextColor,cellTextFontSize:cellTextFontSize,isHeaderVisible:isHeaderVisible,headerBottomSpace:headerBottomSpace,headerTextFontSize:headerTextFontSize,headerTextColor:headerTextColor,headerLabels:headerLabels,headerTextFormat:headerTextFormat,headerTextAlign:props.headerTextAlign,locale:props.locale,scrollStyle:props.scrollStyle,isSidebarVisible:isSidebarVisible,sidebarLabels:sidebarLabels,sidebarTextColor:sidebarTextColor,sideBarTextFontSize:sideBarTextFontSize,getCellColor:getCellColor,onCellPress:props.onCellPress,onMouseEnter:props.onMouseEnter,onMouseLeave:props.onMouseLeave})};export{MonthlyHeatMap,WeeklyHeatMap};